diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index 27c4319..ec787d6 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -1,11 +1,11 @@ +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.AddChannelJingle; using TeleWave.Application.Broadcast.AddChannelShow; -using TeleWave.Application.Broadcast.BumperBackground; -using TeleWave.Application.Broadcast.BumperMusic; +using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.CreateChannel; using TeleWave.Application.Broadcast.CreateOverride; using TeleWave.Application.Broadcast.DeleteOverride; @@ -14,18 +14,23 @@ using TeleWave.Application.Broadcast.GetSchedule; using TeleWave.Application.Broadcast.ListChannels; using TeleWave.Application.Broadcast.RegenerateSchedule; using TeleWave.Application.Broadcast.RemoveChannelAd; -using TeleWave.Application.Broadcast.RemoveChannelJingle; 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 { + 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") @@ -55,24 +60,38 @@ public static class ChannelEndpoints .Produces(StatusCodes.Status204NoContent); admin - .MapPost("/{id:guid}/jingles", AddJingle) + .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate) .Produces(StatusCodes.Status201Created); admin - .MapDelete("/{id:guid}/jingles/{channelJingleId:guid}", RemoveJingle) + .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/background", UploadTemplateBackground) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/background", ClearTemplateBackground) .Produces(StatusCodes.Status204NoContent); admin - .MapPut("/{id:guid}/bumper/background", UploadBackground) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/{id:guid}/bumper/background", ClearBackground) - .Produces(StatusCodes.Status204NoContent); - admin - .MapPut("/{id:guid}/bumper/music", UploadMusic) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/{id:guid}/bumper/music", ClearMusic) + .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview) .Produces(StatusCodes.Status204NoContent); + admin.MapGet( + "/{id:guid}/bumper/templates/{templateId:guid}/preview/index.m3u8", + PreviewPlaylist + ); + admin.MapGet( + "/{id:guid}/bumper/templates/{templateId:guid}/preview/{file}", + PreviewSegment + ); admin .MapPost("/{id:guid}/overrides", CreateOverride) @@ -216,15 +235,15 @@ public static class ChannelEndpoints return result.ToHttpResult(); } - private static async Task AddJingle( + private static async Task AddBumperTemplate( Guid id, - AddChannelJingleBody body, + AddBumperTemplateBody body, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new AddChannelJingleCommand(id, body.MediaAssetId), + new AddBumperTemplateCommand(id, body.Name), cancellationToken ); return result.IsSuccess @@ -232,22 +251,91 @@ public static class ChannelEndpoints : result.ToHttpResult(); } - private static async Task RemoveJingle( + private static async Task UpdateBumperTemplate( Guid id, - Guid channelJingleId, + Guid templateId, + UpdateBumperTemplateBody body, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new RemoveChannelJingleCommand(id, channelJingleId), + new UpdateBumperTemplateCommand( + id, + templateId, + body.Name, + body.BackgroundColor, + body.BackgroundColor2, + body.AccentColor, + body.TextColor + ), cancellationToken ); return result.ToHttpResult(); } - private static async Task UploadBackground( + 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 UploadTemplateBackground( + Guid id, + Guid templateId, string fileName, HttpRequest request, IBumperTemplateStorage storage, @@ -258,52 +346,96 @@ public static class ChannelEndpoints if (ResolveBumperExtension(fileName, request, BumperFiles.BackgroundExtensions) is not { } ext) return ChannelErrors.InvalidBumperFile.ToProblem(); - await storage.SaveBackgroundAsync(id, ext, request.Body, cancellationToken); + await storage.SaveBackgroundAsync(templateId, ext, request.Body, cancellationToken); - var result = await sender.Send(new SetBumperBackgroundCommand(id, ext), cancellationToken); + var result = await sender.Send( + new SetBumperTemplateBackgroundCommand(id, templateId, ext), + cancellationToken + ); if (!result.IsSuccess) - storage.DeleteBackground(id); + storage.DeleteBackground(templateId); return result.ToHttpResult(); } - private static async Task ClearBackground( + private static async Task ClearTemplateBackground( Guid id, + Guid templateId, ISender sender, CancellationToken cancellationToken ) { - var result = await sender.Send(new ClearBumperBackgroundCommand(id), cancellationToken); + var result = await sender.Send( + new ClearBumperTemplateBackgroundCommand(id, templateId), + cancellationToken + ); return result.ToHttpResult(); } - private static async Task UploadMusic( + /// Синхронно рендерит пример заставки блока (несколько секунд ffmpeg). + private static async Task RenderPreview( Guid id, - string fileName, - HttpRequest request, - IBumperTemplateStorage storage, + Guid templateId, ISender sender, CancellationToken cancellationToken ) { - if (ResolveBumperExtension(fileName, request, BumperFiles.MusicExtensions) is not { } ext) - return ChannelErrors.InvalidBumperFile.ToProblem(); - - await storage.SaveMusicAsync(id, ext, request.Body, cancellationToken); - - var result = await sender.Send(new SetBumperMusicCommand(id, ext), cancellationToken); - if (!result.IsSuccess) - storage.DeleteMusic(id); - return result.ToHttpResult(); + var result = await sender.Send( + new RenderBumperPreviewQuery(id, templateId), + cancellationToken + ); + return result.IsSuccess ? Results.NoContent() : result.ToHttpResult(); } - private static async Task ClearMusic( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) + /// Плейлист превью: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. + private static IResult PreviewPlaylist(Guid id, Guid templateId, MediaPathResolver paths) { - var result = await sender.Send(new ClearBumperMusicCommand(id), cancellationToken); - return result.ToHttpResult(); + var previewId = BumperPreview.AssetId(templateId); + 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/"; + 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, string file, MediaPathResolver paths) + { + if (!BumperSegmentFileName.IsMatch(file)) + return Results.NotFound(); + + var previewId = BumperPreview.AssetId(templateId); + 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). Возвращает @@ -405,13 +537,22 @@ public sealed record UpdateChannelShowBody( public sealed record AddChannelAdBody(Guid MediaAssetId); -public sealed record AddChannelJingleBody(Guid MediaAssetId); +public sealed record AddBumperTemplateBody(string Name); -/// Ограничения на загружаемые файлы заставки (фон/музыка). +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 BackgroundExtensions = new HashSet( StringComparer.OrdinalIgnoreCase ) @@ -422,13 +563,9 @@ internal static class BumperFiles ".webp", ".bmp", ".gif", - ".mp4", - ".mov", - ".mkv", - ".webm", }; - public static readonly IReadOnlySet MusicExtensions = new HashSet( + public static readonly IReadOnlySet AudioExtensions = new HashSet( StringComparer.OrdinalIgnoreCase ) { diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommand.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommand.cs deleted file mode 100644 index 60b8f8a..0000000 --- a/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.AddChannelJingle; - -public sealed record AddChannelJingleCommand(Guid ChannelId, Guid MediaAssetId) - : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommandHandler.cs deleted file mode 100644 index 9bac9cc..0000000 --- a/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommandHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.AddChannelJingle; - -public sealed class AddChannelJingleCommandHandler(IAppDbContext dbContext) - : ICommandHandler> -{ - public async Task> Handle( - AddChannelJingleCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext.Channels - .Include(c => c.Jingles) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - var assetExists = await dbContext.MediaAssets.AnyAsync( - a => a.Id == command.MediaAssetId, - cancellationToken - ); - if (!assetExists) - return Result.Failure(ChannelErrors.AssetNotFound); - - if (channel.HasJingle(command.MediaAssetId)) - return Result.Failure(ChannelErrors.JingleAlreadyAdded); - - var jingle = channel.AddJingle(command.MediaAssetId); - return Result.Success(jingle.Id); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommand.cs b/backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommand.cs deleted file mode 100644 index 7347289..0000000 --- a/backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.BumperBackground; - -public sealed record ClearBumperBackgroundCommand(Guid ChannelId) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommandHandler.cs deleted file mode 100644 index 0769616..0000000 --- a/backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommandHandler.cs +++ /dev/null @@ -1,29 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.BumperBackground; - -public sealed class ClearBumperBackgroundCommandHandler( - IAppDbContext dbContext, - IBumperTemplateStorage storage -) : ICommandHandler -{ - public async Task Handle( - ClearBumperBackgroundCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext.Channels.FirstOrDefaultAsync( - c => c.Id == command.ChannelId, - cancellationToken - ); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - channel.ClearBumperBackground(); - storage.DeleteBackground(channel.Id); - return Result.Success(); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommand.cs b/backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommand.cs deleted file mode 100644 index d68b1ea..0000000 --- a/backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.BumperBackground; - -/// Отметить, что для канала загружен фон заставки (файл уже сохранён хранилищем). -public sealed record SetBumperBackgroundCommand(Guid ChannelId, string Extension) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommandHandler.cs deleted file mode 100644 index bdf767c..0000000 --- a/backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommandHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.BumperBackground; - -public sealed class SetBumperBackgroundCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - SetBumperBackgroundCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext.Channels.FirstOrDefaultAsync( - c => c.Id == command.ChannelId, - cancellationToken - ); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - channel.SetBumperBackground(command.Extension); - return Result.Success(); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommand.cs b/backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommand.cs deleted file mode 100644 index dbb6926..0000000 --- a/backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.BumperMusic; - -public sealed record ClearBumperMusicCommand(Guid ChannelId) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommandHandler.cs deleted file mode 100644 index cbfb402..0000000 --- a/backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommandHandler.cs +++ /dev/null @@ -1,29 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.BumperMusic; - -public sealed class ClearBumperMusicCommandHandler( - IAppDbContext dbContext, - IBumperTemplateStorage storage -) : ICommandHandler -{ - public async Task Handle( - ClearBumperMusicCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext.Channels.FirstOrDefaultAsync( - c => c.Id == command.ChannelId, - cancellationToken - ); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - channel.ClearBumperMusic(); - storage.DeleteMusic(channel.Id); - return Result.Success(); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommand.cs b/backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommand.cs deleted file mode 100644 index b464f71..0000000 --- a/backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.BumperMusic; - -/// Отметить, что для канала загружена музыка заставки (файл уже сохранён хранилищем). -public sealed record SetBumperMusicCommand(Guid ChannelId, string Extension) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommandHandler.cs deleted file mode 100644 index 88de9aa..0000000 --- a/backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommandHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.BumperMusic; - -public sealed class SetBumperMusicCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - SetBumperMusicCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext.Channels.FirstOrDefaultAsync( - c => c.Id == command.ChannelId, - cancellationToken - ); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - channel.SetBumperMusic(command.Extension); - return Result.Success(); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommand.cs new file mode 100644 index 0000000..f703fdb --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Добавить новый блок заставки на канал (звук/фон загружаются отдельно). +public sealed record AddBumperTemplateCommand(Guid ChannelId, string Name) : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommandHandler.cs new file mode 100644 index 0000000..c335fdf --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/AddBumperTemplateCommandHandler.cs @@ -0,0 +1,28 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class AddBumperTemplateCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + AddBumperTemplateCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels + .Include(c => c.BumperTemplates) + .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var name = string.IsNullOrWhiteSpace(command.Name) + ? $"Заставка {channel.BumperTemplates.Count + 1}" + : command.Name.Trim(); + var template = channel.AddBumperTemplate(name); + return Result.Success(template.Id); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs new file mode 100644 index 0000000..881061b --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs @@ -0,0 +1,12 @@ +using System.Security.Cryptography; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// +/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер, +/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые. +/// +public static class BumperPreview +{ + public static Guid AssetId(Guid templateId) => new(MD5.HashData(templateId.ToByteArray())); +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommand.cs new file mode 100644 index 0000000..bd9417a --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommand.cs @@ -0,0 +1,8 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Удалить загруженный звук блока (вернуться к синтезированному джинглу). +public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId) + : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs new file mode 100644 index 0000000..4ba2039 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs @@ -0,0 +1,32 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class ClearBumperTemplateAudioCommandHandler( + IAppDbContext dbContext, + IBumperTemplateStorage storage +) : ICommandHandler +{ + public async Task Handle( + ClearBumperTemplateAudioCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels + .Include(c => c.BumperTemplates) + .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = channel.FindBumperTemplate(command.TemplateId); + if (template is null) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + + template.ClearAudio(); + storage.DeleteAudio(command.TemplateId); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommand.cs new file mode 100644 index 0000000..936c2a6 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommand.cs @@ -0,0 +1,8 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Удалить загруженную фон-картинку блока (вернуться к градиенту/постеру). +public sealed record ClearBumperTemplateBackgroundCommand(Guid ChannelId, Guid TemplateId) + : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs new file mode 100644 index 0000000..a8df2ec --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateBackgroundCommandHandler.cs @@ -0,0 +1,32 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class ClearBumperTemplateBackgroundCommandHandler( + IAppDbContext dbContext, + IBumperTemplateStorage storage +) : ICommandHandler +{ + public async Task Handle( + ClearBumperTemplateBackgroundCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels + .Include(c => c.BumperTemplates) + .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = channel.FindBumperTemplate(command.TemplateId); + if (template is null) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + + template.ClearBackgroundImage(); + storage.DeleteBackground(command.TemplateId); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommand.cs new file mode 100644 index 0000000..8ba4cea --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Удалить блок заставки (кроме дефолтного) и его файлы. +public sealed record RemoveBumperTemplateCommand(Guid ChannelId, Guid TemplateId) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs new file mode 100644 index 0000000..b38079d --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs @@ -0,0 +1,34 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class RemoveBumperTemplateCommandHandler( + IAppDbContext dbContext, + IBumperTemplateStorage storage +) : ICommandHandler +{ + public async Task Handle( + RemoveBumperTemplateCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels + .Include(c => c.BumperTemplates) + .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = channel.FindBumperTemplate(command.TemplateId); + if (template is null) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + if (template.IsDefault) + return Result.Failure(ChannelErrors.CannotRemoveDefaultBumperTemplate); + + channel.RemoveBumperTemplate(command.TemplateId); + storage.DeleteTemplate(command.TemplateId); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs new file mode 100644 index 0000000..571091b --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs @@ -0,0 +1,11 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// +/// Синхронно рендерит пример заставки блока (с примерными названиями шоу) и возвращает id +/// ассета-превью. БД не меняет — это read-side генерация артефакта для предпросмотра. +/// +public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId) + : IQuery>; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs new file mode 100644 index 0000000..ba2204c --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs @@ -0,0 +1,90 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Streaming; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class RenderBumperPreviewQueryHandler( + IAppDbContext dbContext, + IBumperRenderer renderer, + IBumperTemplateStorage storage, + IOptions bumperOptions, + IOptions streamingOptions +) : IQueryHandler> +{ + private readonly BumperOptions _bumper = bumperOptions.Value; + private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); + + /// Длительность заставки без загруженного звука (сек) — как в генераторе. + private const int DefaultBumperDurationSeconds = 8; + + public async Task> Handle( + RenderBumperPreviewQuery query, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels.AsNoTracking() + .Include(c => c.BumperTemplates) + .Include(c => c.Shows) + .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId); + if (template is null) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + + var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); + + var seconds = + template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds; + var aligned = (int)( + Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds + ); + + var spec = new BumperRenderSpec( + aligned, + _bumper.Width, + _bumper.Height, + template.BackgroundColor, + template.BackgroundColor2, + template.AccentColor, + template.TextColor, + channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans, + channel.BumperNowLabel, + fromName, + channel.BumperNextLabel, + toName, + storage.BackgroundPath(template.Id, template.BackgroundImageExtension), + storage.AudioPath(template.Id, template.AudioExtension), + // Постер зависит от конкретного «следующего» шоу — в превью не подставляем. + null + ); + + var previewId = BumperPreview.AssetId(template.Id); + await renderer.RenderAsync(previewId, spec, cancellationToken); + return Result.Success(previewId); + } + + /// Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки. + private async Task<(string From, string To)> SampleNamesAsync( + Channel channel, + CancellationToken cancellationToken + ) + { + var showIds = channel.Shows.Select(s => s.ShowId).Distinct().Take(2).ToList(); + var names = showIds.Count == 0 + ? [] + : await dbContext.Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => s.Name) + .Take(2) + .ToListAsync(cancellationToken); + + return (names.ElementAtOrDefault(0) ?? "Первое шоу", names.ElementAtOrDefault(1) ?? "Второе шоу"); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommand.cs new file mode 100644 index 0000000..1adf55c --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommand.cs @@ -0,0 +1,12 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Отметить загруженный звук блока: расширение (с точкой) и длину в секундах (замер ffprobe). +public sealed record SetBumperTemplateAudioCommand( + Guid ChannelId, + Guid TemplateId, + string Extension, + double DurationSeconds +) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs new file mode 100644 index 0000000..caa5bd7 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateAudioCommandHandler.cs @@ -0,0 +1,29 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class SetBumperTemplateAudioCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + SetBumperTemplateAudioCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels + .Include(c => c.BumperTemplates) + .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = channel.FindBumperTemplate(command.TemplateId); + if (template is null) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + + template.SetAudio(command.Extension, command.DurationSeconds); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs new file mode 100644 index 0000000..fde0552 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommand.cs @@ -0,0 +1,11 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Отметить загруженную фон-картинку блока (расширение — с точкой). +public sealed record SetBumperTemplateBackgroundCommand( + Guid ChannelId, + Guid TemplateId, + string Extension +) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs new file mode 100644 index 0000000..d699cd0 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/SetBumperTemplateBackgroundCommandHandler.cs @@ -0,0 +1,29 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + SetBumperTemplateBackgroundCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels + .Include(c => c.BumperTemplates) + .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = channel.FindBumperTemplate(command.TemplateId); + if (template is null) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + + template.SetBackgroundImage(command.Extension); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommand.cs new file mode 100644 index 0000000..f73577e --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommand.cs @@ -0,0 +1,15 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +/// Обновить оформление блока заставки: имя и цвета (в нотации ffmpeg). +public sealed record UpdateBumperTemplateCommand( + Guid ChannelId, + Guid TemplateId, + string Name, + string BackgroundColor, + string BackgroundColor2, + string AccentColor, + string TextColor +) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs new file mode 100644 index 0000000..5d0ae7d --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandHandler.cs @@ -0,0 +1,35 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed class UpdateBumperTemplateCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + UpdateBumperTemplateCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext.Channels + .Include(c => c.BumperTemplates) + .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = channel.FindBumperTemplate(command.TemplateId); + if (template is null) + return Result.Failure(ChannelErrors.BumperTemplateNotFound); + + template.UpdateStyle( + command.Name.Trim(), + command.BackgroundColor, + command.BackgroundColor2, + command.AccentColor, + command.TextColor + ); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandValidator.cs new file mode 100644 index 0000000..53c29ae --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTemplateCommandValidator.cs @@ -0,0 +1,29 @@ +using System.Text.RegularExpressions; +using FluentValidation; + +namespace TeleWave.Application.Broadcast.Bumpers; + +public sealed partial class UpdateBumperTemplateCommandValidator + : AbstractValidator +{ + public UpdateBumperTemplateCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + + // Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат + // (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра. + RuleFor(x => x.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage); + RuleFor(x => x.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage); + RuleFor(x => x.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage); + RuleFor(x => x.TextColor).Must(BeSafeColor).WithMessage(ColorMessage); + } + + private const string ColorMessage = + "Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white)."; + + private static bool BeSafeColor(string? value) => + !string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value); + + [GeneratedRegex(@"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$")] + private static partial Regex ColorRegex(); +} diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index 8082c90..5fd7e6c 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -17,8 +17,6 @@ public sealed record ChannelShowDto( public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position); -public sealed record ChannelJingleDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position); - public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight); public sealed record ProgrammingOverrideDto( @@ -29,20 +27,29 @@ public sealed record ProgrammingOverrideDto( IReadOnlyList Shows ); +/// Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. ). public sealed record BumperSettingsDto( - BumperMode Mode, - int DurationSeconds, - string BackgroundColor, - string BackgroundColor2, - string AccentColor, - string TextColor, BumperFont Font, string NowLabel, string NextLabel, int MinIntervalMinutes, bool OnlyBetweenDifferentShows, + BumperSelection Selection +); + +/// Блок заставки: своё оформление + звук. — длина звука (сек). +public sealed record BumperTemplateDto( + Guid Id, + int Position, + bool IsDefault, + string Name, + string BackgroundColor, + string BackgroundColor2, + string AccentColor, + string TextColor, bool HasBackground, - bool HasMusic + bool HasAudio, + double? AudioDurationSeconds ); public sealed record ChannelDto( @@ -54,9 +61,9 @@ public sealed record ChannelDto( int AdsPerBreak, bool BumpersEnabled, BumperSettingsDto Bumper, + IReadOnlyList BumperTemplates, Guid? FillerAssetId, IReadOnlyList Shows, IReadOnlyList Ads, - IReadOnlyList Jingles, IReadOnlyList Overrides ); diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs index 8248ce4..9a62eea 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs @@ -36,14 +36,14 @@ public static class ChannelErrors "Реклама не найдена в пуле канала." ); - public static readonly Error JingleAlreadyAdded = Error.Conflict( - "Channels.JingleAlreadyAdded", - "Этот ролик уже в пуле джинглов канала." + public static readonly Error BumperTemplateNotFound = Error.NotFound( + "Channels.BumperTemplateNotFound", + "Блок заставки не найден." ); - public static readonly Error JingleNotFound = Error.NotFound( - "Channels.JingleNotFound", - "Джингл не найден в пуле канала." + public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation( + "Channels.CannotRemoveDefaultBumperTemplate", + "Дефолтный блок заставки удалить нельзя." ); public static readonly Error AssetNotFound = Error.NotFound( diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 0a563cb..6213bf5 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -16,7 +16,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) var channel = await dbContext.Channels.AsNoTracking() .Include(c => c.Shows) .Include(c => c.Ads) - .Include(c => c.Jingles) + .Include(c => c.BumperTemplates) .Include(c => c.Overrides) .ThenInclude(o => o.Shows) .AsSplitQuery() @@ -33,10 +33,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) .Select(s => new { s.Id, s.Name }) .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); - var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId) - .Concat(channel.Jingles.Select(j => j.MediaAssetId)) - .Distinct() - .ToList(); + var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId).Distinct().ToList(); var assetNames = await dbContext.MediaAssets.AsNoTracking() .Where(a => poolAssetIds.Contains(a.Id)) .Select(a => new { a.Id, a.OriginalFileName }) @@ -67,13 +64,20 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) )) .ToList(); - var jingles = channel.Jingles - .OrderBy(j => j.Position) - .Select(j => new ChannelJingleDto( - j.Id, - j.MediaAssetId, - assetNames.GetValueOrDefault(j.MediaAssetId), - j.Position + var bumperTemplates = channel.BumperTemplates + .OrderBy(t => t.Position) + .Select(t => new BumperTemplateDto( + t.Id, + t.Position, + t.IsDefault, + t.Name, + t.BackgroundColor, + t.BackgroundColor2, + t.AccentColor, + t.TextColor, + t.BackgroundImageExtension is not null, + t.AudioExtension is not null, + t.AudioDurationSeconds )) .ToList(); @@ -100,24 +104,17 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) channel.AdsPerBreak, channel.BumpersEnabled, new BumperSettingsDto( - channel.BumperMode, - channel.BumperDurationSeconds, - channel.BumperBackgroundColor, - channel.BumperBackgroundColor2, - channel.BumperAccentColor, - channel.BumperTextColor, channel.BumperFont, channel.BumperNowLabel, channel.BumperNextLabel, channel.BumperMinIntervalMinutes, channel.BumperOnlyBetweenDifferentShows, - channel.BumperBackgroundExtension is not null, - channel.BumperMusicExtension is not null + channel.BumperSelection ), + bumperTemplates, channel.FillerAssetId, shows, ads, - jingles, overrides ) ); diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommand.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommand.cs deleted file mode 100644 index ff9c1ca..0000000 --- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LiteCqrs; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.RemoveChannelJingle; - -public sealed record RemoveChannelJingleCommand(Guid ChannelId, Guid ChannelJingleId) - : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommandHandler.cs deleted file mode 100644 index d04486c..0000000 --- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommandHandler.cs +++ /dev/null @@ -1,26 +0,0 @@ -using LiteCqrs; -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; - -namespace TeleWave.Application.Broadcast.RemoveChannelJingle; - -public sealed class RemoveChannelJingleCommandHandler(IAppDbContext dbContext) - : ICommandHandler -{ - public async Task Handle( - RemoveChannelJingleCommand command, - CancellationToken cancellationToken - ) - { - var channel = await dbContext.Channels - .Include(c => c.Jingles) - .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); - if (channel is null) - return Result.Failure(ChannelErrors.NotFound); - - return channel.RemoveJingle(command.ChannelJingleId) - ? Result.Success() - : Result.Failure(ChannelErrors.JingleNotFound); - } -} diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index 4324d31..c34bc33 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -16,8 +16,8 @@ namespace TeleWave.Application.Broadcast.Scheduling; /// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый /// , материализует записи и двигает курсоры. Используется фоновым /// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала). -/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) и подставляются -/// как обычные ассеты. +/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) по выбранному +/// блоку и подставляются как обычные ассеты. /// public sealed class ScheduleGenerator( IAppDbContext dbContext, @@ -35,6 +35,9 @@ public sealed class ScheduleGenerator( private readonly BumperOptions _bumper = bumperOptions.Value; private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); + /// Длительность заставки без загруженного звука (сек) — синтезированный джингл. + private const int DefaultBumperDurationSeconds = 8; + /// /// Достраивает (или, при , перестраивает будущий хвост) расписание /// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен). @@ -49,7 +52,7 @@ public sealed class ScheduleGenerator( var channel = await dbContext.Channels .Include(c => c.Shows) .Include(c => c.Ads) - .Include(c => c.Jingles) + .Include(c => c.BumperTemplates) .Include(c => c.Overrides) .ThenInclude(o => o.Shows) .AsSplitQuery() @@ -92,7 +95,7 @@ public sealed class ScheduleGenerator( var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken); var result = SchedulePlanner.Plan(input, random); - // Рендерим/достаём из кэша ассеты заставок для всех переходов плана. + // Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + блоку). var bumperAssets = await ResolveBumperAssetsAsync( channel, result.Entries, @@ -135,7 +138,7 @@ public sealed class ScheduleGenerator( channelShow.SetNextEpisodeIndex(idx); channel.SetNextAdIndex(result.NextAdIndex); - channel.SetNextJingleIndex(result.NextJingleIndex); + channel.SetNextBumperIndex(result.NextBumperIndex); await dbContext.SaveChangesAsync(cancellationToken); return added; @@ -144,57 +147,56 @@ public sealed class ScheduleGenerator( private static ScheduleEntry? BuildBumperEntry( Guid channelId, PlannedEntry entry, - IReadOnlyDictionary<(Guid, Guid), Guid> bumperAssets + IReadOnlyDictionary<(Guid From, Guid To, Guid Template), Guid> bumperAssets ) { - // Статичный джингл — планировщик уже проставил реальный ассет из пула. - if (entry.MediaAssetId != Guid.Empty) - return ScheduleEntry.Bumper( - channelId, - entry.MediaAssetId, - entry.StartsAtUtc, - entry.EndsAtUtc, - entry.ShowId - ); - - // Динамическая заставка — ассет резолвится по паре шоу (отрендерен/из кэша). + // Заставка резолвится по паре шоу + выбранному блоку (отрендерена/из кэша). Если рендер не + // удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл). if ( entry.FromShowId is not { } from || entry.ToShowId is not { } to - || !bumperAssets.TryGetValue((from, to), out var assetId) + || entry.BumperTemplateId is not { } template + || !bumperAssets.TryGetValue((from, to, template), out var assetId) ) - // Заставку не удалось отрендерить — пропускаем запись (слот заполнит филлер/следующая - // программа). Планировщик уже учёл её длину, поэтому небольшой зазор допустим. return null; return ScheduleEntry.Bumper(channelId, assetId, entry.StartsAtUtc, entry.EndsAtUtc, entry.ToShowId); } /// - /// Для каждой уникальной пары «из→в» из запланированных заставок возвращает id готового + /// Для каждой уникальной тройки «из→в→блок» из запланированных заставок возвращает id готового /// ассета-заставки: из кэша () либо свежесгенерированного. /// - private async Task> ResolveBumperAssetsAsync( + private async Task> ResolveBumperAssetsAsync( Channel channel, IReadOnlyList entries, IReadOnlyDictionary showNames, CancellationToken cancellationToken ) { - var result = new Dictionary<(Guid, Guid), Guid>(); - var styleSignature = BumperStyleSignature(channel); - var pairs = entries - .Where(e => e.Kind == ScheduleEntryKind.Bumper && e.FromShowId is not null && e.ToShowId is not null) - .Select(e => (From: e.FromShowId!.Value, To: e.ToShowId!.Value)) + var result = new Dictionary<(Guid, Guid, Guid), Guid>(); + var templatesById = channel.BumperTemplates.ToDictionary(t => t.Id); + var combos = entries + .Where(e => + e.Kind == ScheduleEntryKind.Bumper + && e.FromShowId is not null + && e.ToShowId is not null + && e.BumperTemplateId is not null + ) + .Select(e => ( + From: e.FromShowId!.Value, + To: e.ToShowId!.Value, + Template: e.BumperTemplateId!.Value + )) .Distinct() .ToList(); - if (pairs.Count == 0) + if (combos.Count == 0) return result; - var fromIds = pairs.Select(p => p.From).Distinct().ToList(); - var toIds = pairs.Select(p => p.To).Distinct().ToList(); + var fromIds = combos.Select(c => c.From).Distinct().ToList(); + var toIds = combos.Select(c => c.To).Distinct().ToList(); - // Постеры шоу-получателей — как фон заставки (если у канала нет своего фона). + // Постеры шоу-получателей — как фон заставки (если у блока нет своей фон-картинки). var showIds = fromIds.Concat(toIds).Distinct().ToList(); var posterByShow = await dbContext.Shows.AsNoTracking() .Where(s => showIds.Contains(s.Id) && s.PosterPath != null) @@ -220,22 +222,26 @@ public sealed class ScheduleGenerator( .ToListAsync(cancellationToken); var readySet = readyAssetIds.ToHashSet(); - foreach (var pair in pairs) + foreach (var combo in combos) { - var fromName = showNames.GetValueOrDefault(pair.From, "…"); - var toName = showNames.GetValueOrDefault(pair.To, "…"); - var toPosterRel = posterByShow.GetValueOrDefault(pair.To); - var signature = ComputeSignature(fromName, toName, styleSignature, toPosterRel ?? "-"); + if (!templatesById.TryGetValue(combo.Template, out var template)) + continue; + + var fromName = showNames.GetValueOrDefault(combo.From, "…"); + var toName = showNames.GetValueOrDefault(combo.To, "…"); + var toPosterRel = posterByShow.GetValueOrDefault(combo.To); + var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template)); + var signature = ComputeSignature(channel, template, fromName, toName, aligned, toPosterRel ?? "-"); var hit = cached.FirstOrDefault(c => - c.FromShowId == pair.From - && c.ToShowId == pair.To + c.FromShowId == combo.From + && c.ToShowId == combo.To && c.Signature == signature && readySet.Contains(c.MediaAssetId) ); if (hit is not null) { - result[pair] = hit.MediaAssetId; + result[combo] = hit.MediaAssetId; continue; } @@ -243,15 +249,17 @@ public sealed class ScheduleGenerator( { var assetId = await RenderBumperAsync( channel, - pair.From, - pair.To, + template, + combo.From, + combo.To, fromName, toName, + aligned, signature, toPosterRel, cancellationToken ); - result[pair] = assetId; + result[combo] = assetId; } catch (Exception ex) { @@ -269,10 +277,12 @@ public sealed class ScheduleGenerator( private async Task RenderBumperAsync( Channel channel, + BumperTemplate template, Guid fromShowId, Guid toShowId, string fromName, string toName, + int alignedDurationSeconds, string signature, string? toPosterRelative, CancellationToken cancellationToken @@ -284,7 +294,7 @@ public sealed class ScheduleGenerator( : metadataImages.ResolveAbsolutePath(toPosterRelative); var render = await bumperRenderer.RenderAsync( asset.Id, - BuildSpec(channel, fromName, toName, posterAbs), + BuildSpec(channel, template, alignedDurationSeconds, fromName, toName, posterAbs), cancellationToken ); @@ -308,67 +318,78 @@ public sealed class ScheduleGenerator( private BumperRenderSpec BuildSpec( Channel channel, + BumperTemplate template, + int alignedDurationSeconds, string fromName, string toName, string? posterAbsolutePath ) => new( - AlignedBumperDuration(channel), + alignedDurationSeconds, _bumper.Width, _bumper.Height, - channel.BumperBackgroundColor, - channel.BumperBackgroundColor2, - channel.BumperAccentColor, - channel.BumperTextColor, + template.BackgroundColor, + template.BackgroundColor2, + template.AccentColor, + template.TextColor, FontPath(channel.BumperFont), channel.BumperNowLabel, fromName, channel.BumperNextLabel, toName, - bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension), - bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension), + bumperStorage.BackgroundPath(template.Id, template.BackgroundImageExtension), + bumperStorage.AudioPath(template.Id, template.AudioExtension), posterAbsolutePath ); private string FontPath(BumperFont font) => font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; - /// Длительность заставки канала, выровненная вверх до кратности сегменту. - private int AlignedBumperDuration(Channel channel) + /// Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза. + private static double TemplateDurationSeconds(BumperTemplate template) => + template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds; + + /// Длительность, выровненная вверх до кратности длине сегмента (инвариант раздачи). + private int AlignedDurationSeconds(double seconds) { - var requested = Math.Max(_segmentSeconds, channel.BumperDurationSeconds); - return (int)(Math.Ceiling((double)requested / _segmentSeconds) * _segmentSeconds); + var requested = Math.Max(_segmentSeconds, seconds); + return (int)(Math.Ceiling(requested / _segmentSeconds) * _segmentSeconds); } - /// Сигнатура оформления канала — входит в кэш-ключ, чтобы правка стиля пересобирала заставки. - private string BumperStyleSignature(Channel channel) => - string.Join( - '|', - _bumper.TemplateVersion, - AlignedBumperDuration(channel), - _bumper.Width, - _bumper.Height, - channel.BumperBackgroundColor, - channel.BumperBackgroundColor2, - channel.BumperAccentColor, - channel.BumperTextColor, - channel.BumperFont, - channel.BumperNowLabel, - channel.BumperNextLabel, - // Ревизия + расширения файлов: замена загруженного фона/музыки пересобирает заставки. - channel.BumperRevision, - channel.BumperBackgroundExtension ?? "-", - channel.BumperMusicExtension ?? "-" - ); - - private static string ComputeSignature( + /// + /// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия + /// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется — + /// заставка пересобирается. + /// + private string ComputeSignature( + Channel channel, + BumperTemplate template, string fromName, string toName, - string styleSignature, + int alignedDurationSeconds, string poster ) { - var raw = string.Join('', fromName, toName, styleSignature, poster); + var raw = string.Join( + '', + _bumper.TemplateVersion, + _bumper.Width, + _bumper.Height, + alignedDurationSeconds, + channel.BumperFont, + channel.BumperNowLabel, + channel.BumperNextLabel, + template.BackgroundColor, + template.BackgroundColor2, + template.AccentColor, + template.TextColor, + template.Revision, + template.BackgroundImageExtension ?? "-", + template.AudioExtension ?? "-", + fromName, + toName, + poster + ); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); return Convert.ToHexString(hash); } @@ -411,7 +432,6 @@ public sealed class ScheduleGenerator( var candidateAssetIds = episodesByShow.Values .SelectMany(x => x) .Concat(channel.Ads.Select(a => a.MediaAssetId)) - .Concat(channel.Jingles.Select(j => j.MediaAssetId)) .Distinct() .ToList(); @@ -452,10 +472,13 @@ public sealed class ScheduleGenerator( .Where(durations.ContainsKey) .ToList(); - var jinglePool = channel.Jingles - .OrderBy(j => j.Position) - .Select(j => j.MediaAssetId) - .Where(durations.ContainsKey) + // Блоки заставок: длительность слота — по звуку (или дефолт), выровнена на сегмент. + var bumperTemplates = channel.BumperTemplates + .OrderBy(t => t.Position) + .Select(t => new PlannerBumperTemplate( + t.Id, + TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t))) + )) .ToList(); var overrides = channel.Overrides @@ -469,11 +492,10 @@ public sealed class ScheduleGenerator( var bumpers = new PlannerBumperConfig( channel.BumpersEnabled, - TimeSpan.FromSeconds(AlignedBumperDuration(channel)), channel.BumperOnlyBetweenDifferentShows, TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes), - channel.BumperMode, - jinglePool + channel.BumperSelection, + bumperTemplates ); return new PlannerInput( @@ -488,7 +510,7 @@ public sealed class ScheduleGenerator( startTime, horizonEnd, bumpers, - channel.NextJingleIndex + channel.NextBumperIndex ); } } diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs index e4ab7f8..c8d579a 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs @@ -15,17 +15,12 @@ public sealed record UpdateChannelSettingsCommand( Guid? FillerAssetId ) : ICommand; -/// Оформление и правила ТВ-заставок канала (см. Channel.UpdateBumperSettings). +/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings). public sealed record BumperSettingsInput( - BumperMode Mode, - int DurationSeconds, - string BackgroundColor, - string BackgroundColor2, - string AccentColor, - string TextColor, BumperFont Font, string NowLabel, string NextLabel, int MinIntervalMinutes, - bool OnlyBetweenDifferentShows + bool OnlyBetweenDifferentShows, + BumperSelection Selection ); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs index e9f4e2f..c3100ba 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs @@ -36,17 +36,12 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) command.FillerAssetId ); channel.UpdateBumperSettings( - command.Bumper.Mode, - command.Bumper.DurationSeconds, - command.Bumper.BackgroundColor, - command.Bumper.BackgroundColor2, - command.Bumper.AccentColor, - command.Bumper.TextColor, command.Bumper.Font, command.Bumper.NowLabel, command.Bumper.NextLabel, command.Bumper.MinIntervalMinutes, - command.Bumper.OnlyBetweenDifferentShows + command.Bumper.OnlyBetweenDifferentShows, + command.Bumper.Selection ); return Result.Success(); } diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs index 242e290..8c3be01 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs @@ -1,9 +1,8 @@ -using System.Text.RegularExpressions; using FluentValidation; namespace TeleWave.Application.Broadcast.UpdateChannelSettings; -public sealed partial class UpdateChannelSettingsCommandValidator +public sealed class UpdateChannelSettingsCommandValidator : AbstractValidator { public UpdateChannelSettingsCommandValidator() @@ -11,25 +10,8 @@ public sealed partial class UpdateChannelSettingsCommandValidator RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10); - RuleFor(x => x.Bumper.DurationSeconds).InclusiveBetween(2, 30); RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440); RuleFor(x => x.Bumper.NowLabel).MaximumLength(64); RuleFor(x => x.Bumper.NextLabel).MaximumLength(64); - - // Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат - // (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра. - RuleFor(x => x.Bumper.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage); - RuleFor(x => x.Bumper.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage); - RuleFor(x => x.Bumper.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage); - RuleFor(x => x.Bumper.TextColor).Must(BeSafeColor).WithMessage(ColorMessage); } - - private const string ColorMessage = - "Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white)."; - - private static bool BeSafeColor(string? value) => - !string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value); - - [GeneratedRegex(@"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$")] - private static partial Regex ColorRegex(); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAudioProbe.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAudioProbe.cs new file mode 100644 index 0000000..f8106ab --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAudioProbe.cs @@ -0,0 +1,8 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// Замер длительности аудиофайла (ffprobe). Нужен, чтобы длина заставки шла по длине звука. +public interface IAudioProbe +{ + /// Длительность файла по абсолютному пути или null, если определить не удалось. + Task TryGetDurationAsync(string absolutePath, CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs index ed93fff..0d0167a 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs @@ -1,31 +1,34 @@ namespace TeleWave.Application.Common.Interfaces; /// -/// Хранилище сырых файлов шаблона заставки канала (фон и музыка) под bumpers/{channelId}. В отличие +/// Хранилище сырых файлов блоков заставок (звук и фон-картинка) под bumpers/{templateId}. В отличие /// от обычных ассетов эти файлы НЕ режутся на HLS — они подаются как входы в рендер заставки. /// public interface IBumperTemplateStorage { + Task SaveAudioAsync( + Guid templateId, + string extension, + Stream content, + CancellationToken cancellationToken + ); + Task SaveBackgroundAsync( - Guid channelId, + Guid templateId, string extension, Stream content, CancellationToken cancellationToken ); - Task SaveMusicAsync( - Guid channelId, - string extension, - Stream content, - CancellationToken cancellationToken - ); + void DeleteAudio(Guid templateId); + void DeleteBackground(Guid templateId); - void DeleteBackground(Guid channelId); - void DeleteMusic(Guid channelId); + /// Удалить все файлы блока (при удалении самого блока). + void DeleteTemplate(Guid templateId); - /// Абсолютный путь к загруженному фону или null (нет расширения / файл отсутствует). - string? BackgroundPath(Guid channelId, string? extension); + /// Абсолютный путь к загруженному звуку или null (нет расширения / файл отсутствует). + string? AudioPath(Guid templateId, string? extension); - /// Абсолютный путь к загруженной музыке или null. - string? MusicPath(Guid channelId, string? extension); + /// Абсолютный путь к загруженной фон-картинке или null. + string? BackgroundPath(Guid templateId, string? extension); } diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperMode.cs b/backend/src/TeleWave.Domain/Broadcast/BumperMode.cs deleted file mode 100644 index 908b0ec..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/BumperMode.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// Какие заставки вставлять на переходах. -public enum BumperMode -{ - /// Только динамические «Сейчас/Далее», отрисованные по оформлению канала. - Dynamic, - - /// Только готовые ролики-джинглы из пула канала (по кругу). - Static, - - /// И то, и другое — чередуя на соседних переходах. - Both, -} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs new file mode 100644 index 0000000..1d8756c --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs @@ -0,0 +1,14 @@ +namespace TeleWave.Domain.Broadcast; + +/// Как выбирать блок заставки на каждом переходе между шоу. +public enum BumperSelection +{ + /// По кругу в порядке блоков (курсор ). + Rotation, + + /// Случайный блок на каждом переходе. + Random, + + /// Всегда первый (дефолтный) блок. + AlwaysFirst, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs new file mode 100644 index 0000000..5f1cd27 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTemplate.cs @@ -0,0 +1,116 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка). На +/// переходе между шоу генератор рендерит «Сейчас/Далее» стилем блока поверх его звука; длительность +/// заставки определяется длиной звука (выравнивается на сегмент при рендере). Общие для канала шрифт, +/// подписи и правила показа живут на . +/// +/// Первый блок ( == 0) — дефолтный, не удаляется; если звук в нём не загружен, +/// рендер синтезирует джингл по умолчанию. +/// +public class BumperTemplate +{ + public Guid Id { get; private set; } + public Guid ChannelId { get; private set; } + + /// Порядковый номер (0 — дефолтный блок). Используется ротацией и как признак дефолта. + public int Position { get; private set; } + + public string Name { get; private set; } = string.Empty; + + // ── Оформление блока (цвета — в нотации ffmpeg: 0xRRGGBB или имя) ── + public string BackgroundColor { get; private set; } = DefaultBackgroundColor; + public string BackgroundColor2 { get; private set; } = DefaultBackgroundColor2; + public string AccentColor { get; private set; } = DefaultAccentColor; + public string TextColor { get; private set; } = DefaultTextColor; + + /// Расширение загруженной фон-картинки (с точкой) или null — тогда фон градиент/постер. + public string? BackgroundImageExtension { get; private set; } + + /// Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл. + public string? AudioExtension { get; private set; } + + /// Длина загруженного звука в секундах (замер ffprobe) или null, если звука нет. + public double? AudioDurationSeconds { get; private set; } + + /// Версия файлов блока (звук/фон). Входит в кэш-ключ рендера — замена файла пересобирает заставки. + public int Revision { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public const string DefaultBackgroundColor = "0x0b1020"; + public const string DefaultBackgroundColor2 = "0x1e293b"; + public const string DefaultAccentColor = "0x38bdf8"; + public const string DefaultTextColor = "white"; + + public bool IsDefault => Position == 0; + + private BumperTemplate() { } + + internal static BumperTemplate Create(Guid channelId, int position, string name) => + new() + { + Id = Guid.NewGuid(), + ChannelId = channelId, + Position = position, + Name = name, + BackgroundColor = DefaultBackgroundColor, + BackgroundColor2 = DefaultBackgroundColor2, + AccentColor = DefaultAccentColor, + TextColor = DefaultTextColor, + BackgroundImageExtension = null, + AudioExtension = null, + AudioDurationSeconds = null, + Revision = 0, + CreatedAt = DateTimeOffset.UtcNow, + }; + + /// Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя). + public void UpdateStyle( + string name, + string backgroundColor, + string backgroundColor2, + string accentColor, + string textColor + ) + { + Name = name; + BackgroundColor = backgroundColor; + BackgroundColor2 = backgroundColor2; + AccentColor = accentColor; + TextColor = textColor; + } + + /// Отметить загруженный звук (extension — с точкой, нижний регистр) и его длину. Меняет ревизию. + public void SetAudio(string extension, double durationSeconds) + { + AudioExtension = extension; + AudioDurationSeconds = durationSeconds > 0 ? durationSeconds : null; + Revision++; + } + + public void ClearAudio() + { + if (AudioExtension is null && AudioDurationSeconds is null) + return; + AudioExtension = null; + AudioDurationSeconds = null; + Revision++; + } + + /// Отметить загруженную фон-картинку (extension — с точкой, нижний регистр). Меняет ревизию. + public void SetBackgroundImage(string extension) + { + BackgroundImageExtension = extension; + Revision++; + } + + public void ClearBackgroundImage() + { + if (BackgroundImageExtension is null) + return; + BackgroundImageExtension = null; + Revision++; + } +} diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs index c762136..cc7a584 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -10,7 +10,7 @@ public class Channel private readonly List _shows = new(); private readonly List _ads = new(); private readonly List _overrides = new(); - private readonly List _jingles = new(); + private readonly List _bumperTemplates = new(); public Guid Id { get; private set; } public string Name { get; private set; } = string.Empty; @@ -26,28 +26,14 @@ public class Channel /// Вставлять ли ТВ-заставки на переходах между разными шоу. public bool BumpersEnabled { get; private set; } - /// Какие заставки вставлять: динамические «Сейчас/Далее», статичные джинглы или оба. - public BumperMode BumperMode { get; private set; } + // ── Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. BumperTemplates) ── - /// Расширение загруженного фона (с точкой) или null — тогда синтезируется градиент. - public string? BumperBackgroundExtension { get; private set; } + /// Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый). + public BumperSelection BumperSelection { get; private set; } - /// Расширение загруженной музыки (с точкой) или null — тогда синтезируется джингл. - public string? BumperMusicExtension { get; private set; } + /// Курсор ротации блоков заставок. + public int NextBumperIndex { get; private set; } - /// Счётчик версии файлов заставки (фон/музыка). Входит в кэш-ключ, чтобы замена файла тем - /// же именем пересобирала уже отрендеренные динамические заставки. - public int BumperRevision { get; private set; } - - /// Курсор ротации пула джинглов. - public int NextJingleIndex { get; private set; } - - // ── Оформление и правила ТВ-заставок (значения на канал; см. UpdateBumperSettings) ── - public int BumperDurationSeconds { get; private set; } - public string BumperBackgroundColor { get; private set; } = DefaultBackgroundColor; - public string BumperBackgroundColor2 { get; private set; } = DefaultBackgroundColor2; - public string BumperAccentColor { get; private set; } = DefaultAccentColor; - public string BumperTextColor { get; private set; } = DefaultTextColor; public BumperFont BumperFont { get; private set; } public string BumperNowLabel { get; private set; } = DefaultNowLabel; public string BumperNextLabel { get; private set; } = DefaultNextLabel; @@ -58,13 +44,9 @@ public class Channel /// Ставить заставку только на смене шоу (иначе — и внутри марафона одного шоу). public bool BumperOnlyBetweenDifferentShows { get; private set; } - private const int DefaultBumperDurationSeconds = 8; - private const string DefaultBackgroundColor = "0x0b1020"; - private const string DefaultBackgroundColor2 = "0x1e293b"; - private const string DefaultAccentColor = "0x38bdf8"; - private const string DefaultTextColor = "white"; private const string DefaultNowLabel = "СЕЙЧАС"; private const string DefaultNextLabel = "ДАЛЕЕ"; + private const string DefaultTemplateName = "Заставка 1"; /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка). public Guid? FillerAssetId { get; private set; } @@ -80,13 +62,14 @@ public class Channel public IReadOnlyList Ads => _ads; public IReadOnlyList Overrides => _overrides; - /// Пул джинглов-отбивок; порядок ротации — по . - public IReadOnlyList Jingles => _jingles; + /// Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position. + public IReadOnlyList BumperTemplates => _bumperTemplates; private Channel() { } - public static Channel Create(string name, string slug, DateTimeOffset epochUtc) => - new() + public static Channel Create(string name, string slug, DateTimeOffset epochUtc) + { + var channel = new Channel { Id = Guid.NewGuid(), Name = name, @@ -96,16 +79,8 @@ public class Channel AdInsertion = AdInsertion.BetweenBlocks, AdsPerBreak = 1, BumpersEnabled = false, - BumperMode = BumperMode.Dynamic, - BumperBackgroundExtension = null, - BumperMusicExtension = null, - BumperRevision = 0, - NextJingleIndex = 0, - BumperDurationSeconds = DefaultBumperDurationSeconds, - BumperBackgroundColor = DefaultBackgroundColor, - BumperBackgroundColor2 = DefaultBackgroundColor2, - BumperAccentColor = DefaultAccentColor, - BumperTextColor = DefaultTextColor, + BumperSelection = BumperSelection.Rotation, + NextBumperIndex = 0, BumperFont = BumperFont.Sans, BumperNowLabel = DefaultNowLabel, BumperNextLabel = DefaultNextLabel, @@ -114,6 +89,10 @@ public class Channel NextAdIndex = 0, CreatedAt = DateTimeOffset.UtcNow, }; + // На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл). + channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName)); + return channel; + } public void UpdateSettings( string name, @@ -132,85 +111,48 @@ public class Channel FillerAssetId = fillerAssetId; } - /// Оформление и правила ТВ-заставок канала. Цвета — в нотации ffmpeg (0xRRGGBB или имя). + /// Общие настройки ТВ-заставок канала: шрифт, подписи, правила показа и стратегия выбора блока. public void UpdateBumperSettings( - BumperMode mode, - int durationSeconds, - string backgroundColor, - string backgroundColor2, - string accentColor, - string textColor, BumperFont font, string nowLabel, string nextLabel, int minIntervalMinutes, - bool onlyBetweenDifferentShows + bool onlyBetweenDifferentShows, + BumperSelection selection ) { - BumperMode = mode; - BumperDurationSeconds = durationSeconds; - BumperBackgroundColor = backgroundColor; - BumperBackgroundColor2 = backgroundColor2; - BumperAccentColor = accentColor; - BumperTextColor = textColor; BumperFont = font; BumperNowLabel = nowLabel; BumperNextLabel = nextLabel; BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes); BumperOnlyBetweenDifferentShows = onlyBetweenDifferentShows; + BumperSelection = selection; } - /// Отметить загруженный фон (extension — с точкой, нижний регистр). Меняет ревизию. - public void SetBumperBackground(string extension) + /// Добавить блок заставки в конец списка. Возвращает созданный блок. + public BumperTemplate AddBumperTemplate(string name) { - BumperBackgroundExtension = extension; - BumperRevision++; + var nextPosition = _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1; + var template = BumperTemplate.Create(Id, nextPosition, name); + _bumperTemplates.Add(template); + return template; } - public void ClearBumperBackground() - { - if (BumperBackgroundExtension is null) - return; - BumperBackgroundExtension = null; - BumperRevision++; - } + public BumperTemplate? FindBumperTemplate(Guid templateId) => + _bumperTemplates.FirstOrDefault(t => t.Id == templateId); - /// Отметить загруженную музыку (extension — с точкой, нижний регистр). Меняет ревизию. - public void SetBumperMusic(string extension) + /// Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false. + public bool RemoveBumperTemplate(Guid templateId) { - BumperMusicExtension = extension; - BumperRevision++; - } - - public void ClearBumperMusic() - { - if (BumperMusicExtension is null) - return; - BumperMusicExtension = null; - BumperRevision++; - } - - public ChannelJingle AddJingle(Guid mediaAssetId) - { - var nextPosition = _jingles.Count == 0 ? 0 : _jingles.Max(j => j.Position) + 1; - var jingle = ChannelJingle.Create(Id, mediaAssetId, nextPosition); - _jingles.Add(jingle); - return jingle; - } - - public bool RemoveJingle(Guid channelJingleId) - { - var jingle = _jingles.FirstOrDefault(j => j.Id == channelJingleId); - if (jingle is null) + var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId); + if (template is null || template.IsDefault) return false; - _jingles.Remove(jingle); + _bumperTemplates.Remove(template); return true; } - public bool HasJingle(Guid mediaAssetId) => _jingles.Any(j => j.MediaAssetId == mediaAssetId); - - /// Планировщик двигает курсор пула джинглов по мере вставки отбивок. - public void SetNextJingleIndex(int index) => NextJingleIndex = index; + /// Планировщик двигает курсор ротации блоков заставок по мере вставки. + public void SetNextBumperIndex(int index) => NextBumperIndex = index; public ChannelShow? FindShow(Guid channelShowId) => _shows.FirstOrDefault(s => s.Id == channelShowId); diff --git a/backend/src/TeleWave.Domain/Broadcast/ChannelJingle.cs b/backend/src/TeleWave.Domain/Broadcast/ChannelJingle.cs deleted file mode 100644 index 08e59ca..0000000 --- a/backend/src/TeleWave.Domain/Broadcast/ChannelJingle.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace TeleWave.Domain.Broadcast; - -/// Готовый ролик-джингл (отбивка) в пуле канала. Крутятся по кругу в порядке -/// на переходах между шоу, когда режим заставок — Static или Both. -public class ChannelJingle -{ - public Guid Id { get; private set; } - public Guid ChannelId { get; private set; } - public Guid MediaAssetId { get; private set; } - public int Position { get; private set; } - - private ChannelJingle() { } - - internal static ChannelJingle Create(Guid channelId, Guid mediaAssetId, int position) => - new() - { - Id = Guid.NewGuid(), - ChannelId = channelId, - MediaAssetId = mediaAssetId, - Position = position, - }; -} diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs index c60cb01..0ec8c73 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs @@ -19,18 +19,17 @@ public static class SchedulePlanner var byShowId = input.Shows.ToDictionary(s => s.ShowId); var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex); var nextAd = input.NextAdIndex; - var nextJingle = input.NextJingleIndex; + var nextBumper = input.NextBumperIndex; // Есть ли вообще из чего строить эфир. var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0); if (!anyPlayable) - return new PlannerResult(entries, nextEpisode, nextAd, nextJingle); + return new PlannerResult(entries, nextEpisode, nextAd, nextBumper); var cursor = input.StartTime; var iterations = 0; Guid? prevShowId = null; DateTimeOffset? lastBumperAt = null; - var bumperCount = 0; while (cursor < input.HorizonEnd && iterations++ < IterationBackstop) { @@ -40,8 +39,8 @@ public static class SchedulePlanner var pick = WeightedPick(candidates, random); - // ТВ-заставка на переходе. Динамическую (Сейчас/Далее) резервируем слотом фикс. длины — - // ассет отрендерит оркестратор; статичный джингл берём готовым из пула (реальная длина). + // ТВ-заставка на переходе. Резервируем слот выбранного блока фикс. длины — конкретный + // отрендеренный ассет («Сейчас/Далее» стилем блока поверх его звука) подставит оркестратор. if ( prevShowId is { } prev && input.Bumpers is { Enabled: true } bumper @@ -54,11 +53,8 @@ public static class SchedulePlanner ) { var bumperStart = cursor; - if (TryPlaceBumper(entries, bumper, bumperCount, prev, pick.ShowId, input, ref nextJingle, ref cursor)) - { + if (TryPlaceBumper(entries, bumper, prev, pick.ShowId, random, ref nextBumper, ref cursor)) lastBumperAt = bumperStart; - bumperCount++; - } } var blockStart = cursor; @@ -94,75 +90,62 @@ public static class SchedulePlanner prevShowId = pick.ShowId; } - return new PlannerResult(entries, nextEpisode, nextAd, nextJingle); + return new PlannerResult(entries, nextEpisode, nextAd, nextBumper); } /// - /// Ставит одну заставку на переходе по режиму канала. Динамическая — плейсхолдер фикс. длины - /// (ассет подставит оркестратор). Статичная — готовый джингл из пула (реальная длина, курсор - /// двигается). В режиме Both типы чередуются; при пустом пуле Both уходит в динамику. - /// Возвращает true, если заставка добавлена (курсор сдвинут). + /// Ставит на переходе заставку выбранного блока: резервирует слот его длины и оставляет + /// плейсхолдер с парой шоу + id блока (ассет отрендерит оркестратор). Выбор блока — по стратегии + /// канала (ротация двигает курсор). Возвращает true, если заставка добавлена (курсор сдвинут). /// private static bool TryPlaceBumper( List entries, PlannerBumperConfig bumper, - int bumperCount, Guid fromShowId, Guid toShowId, - PlannerInput input, - ref int nextJingle, + IRandomSource random, + ref int nextBumper, ref DateTimeOffset cursor ) { - var pool = bumper.JinglePool; - var hasPool = pool is { Count: > 0 }; - - var wantStatic = - bumper.Mode == BumperMode.Static - || (bumper.Mode == BumperMode.Both && bumperCount % 2 == 1); - - // В режиме Both при пустом пуле показываем динамику. - if (wantStatic && !hasPool && bumper.Mode == BumperMode.Both) - wantStatic = false; - - if (wantStatic) - { - if (!hasPool) - return false; // Static без пула — вставлять нечего. - - var idx = ((nextJingle % pool!.Count) + pool.Count) % pool.Count; - var assetId = pool[idx]; - nextJingle++; - var dur = DurationOf(assetId, input); - if (dur <= TimeSpan.Zero) - return false; - - var end = cursor + dur; - entries.Add( - new PlannedEntry(assetId, ScheduleEntryKind.Bumper, cursor, end, null, null) - ); - cursor = end; - return true; - } - - // Динамическая заставка «Сейчас/Далее» — плейсхолдер с парой шоу для рендера. - if (bumper.Duration <= TimeSpan.Zero) + var templates = bumper.Templates; + if (templates is not { Count: > 0 }) return false; - var dynEnd = cursor + bumper.Duration; + PlannerBumperTemplate template; + switch (bumper.Selection) + { + case BumperSelection.Random: + template = templates[random.Next(templates.Count)]; + break; + case BumperSelection.AlwaysFirst: + template = templates[0]; + break; + default: // Rotation + var idx = ((nextBumper % templates.Count) + templates.Count) % templates.Count; + template = templates[idx]; + nextBumper++; + break; + } + + if (template.Duration <= TimeSpan.Zero) + return false; + + var end = cursor + template.Duration; entries.Add( new PlannedEntry( Guid.Empty, ScheduleEntryKind.Bumper, cursor, - dynEnd, + end, toShowId, null, fromShowId, - toShowId + toShowId, + template.TemplateId ) ); - cursor = dynEnd; + cursor = end; return true; } diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs index 70eeeba..001aeea 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs @@ -22,19 +22,22 @@ public sealed record PlannerOverride( public sealed record PlannerOverrideShow(Guid ShowId, int Weight); /// -/// Политика ТВ-заставок на переходах. должна быть кратна длине сегмента -/// (генератор выравнивает). Планировщик резервирует под заставку слот этой длины, а конкретный -/// сгенерированный ассет подставляет уже оркестратор. +/// Политика ТВ-заставок на переходах. Планировщик выбирает блок () по +/// стратегии и резервирует слот его длины (, +/// уже выровнена генератором на сегмент). Конкретный отрендеренный ассет подставляет оркестратор +/// по паре шоу + выбранному блоку. /// public sealed record PlannerBumperConfig( bool Enabled, - TimeSpan Duration, bool OnlyBetweenDifferentShows, TimeSpan MinInterval, - BumperMode Mode = BumperMode.Dynamic, - IReadOnlyList? JinglePool = null + BumperSelection Selection, + IReadOnlyList Templates ); +/// Блок заставки в терминах планировщика: id + длительность слота (кратна сегменту). +public sealed record PlannerBumperTemplate(Guid TemplateId, TimeSpan Duration); + /// Полный вход планировщика для одного прогона по каналу. public sealed record PlannerInput( Guid ChannelId, @@ -48,13 +51,14 @@ public sealed record PlannerInput( DateTimeOffset StartTime, DateTimeOffset HorizonEnd, PlannerBumperConfig? Bumpers = null, - int NextJingleIndex = 0 + int NextBumperIndex = 0 ); /// /// Одна запланированная запись (ещё не доменная сущность). Для заставок ( == /// ) пуст — его подставит -/// оркестратор после рендера по паре (). +/// оркестратор после рендера по паре () и выбранному +/// блоку (). /// public sealed record PlannedEntry( Guid MediaAssetId, @@ -64,13 +68,14 @@ public sealed record PlannedEntry( Guid? ShowId, int? EpisodeIndex, Guid? FromShowId = null, - Guid? ToShowId = null + Guid? ToShowId = null, + Guid? BumperTemplateId = null ); -/// Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы). +/// Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow, рекламы, заставок). public sealed record PlannerResult( IReadOnlyList Entries, IReadOnlyDictionary NextEpisodeIndexByChannelShow, int NextAdIndex, - int NextJingleIndex + int NextBumperIndex ); diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index ca441ab..80ffd4e 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -136,6 +136,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs b/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs index a55cf15..78048e5 100644 --- a/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs @@ -3,67 +3,74 @@ using TeleWave.Application.Common.Interfaces; namespace TeleWave.Infrastructure.Media; /// -/// Файловое хранилище шаблонов заставок: сырые фон/музыка под bumpers/{channelId}/{kind}{ext}. -/// На канал — не более одного файла каждого вида (при загрузке старый удаляется). +/// Файловое хранилище блоков заставок: сырые звук/фон под bumpers/{templateId}/{kind}{ext}. +/// На блок — не более одного файла каждого вида (при загрузке старый удаляется). /// public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemplateStorage { + private const string Audio = "audio"; private const string Background = "background"; - private const string Music = "music"; + + public Task SaveAudioAsync( + Guid templateId, + string extension, + Stream content, + CancellationToken cancellationToken + ) => SaveAsync(templateId, Audio, extension, content, cancellationToken); public Task SaveBackgroundAsync( - Guid channelId, + Guid templateId, string extension, Stream content, CancellationToken cancellationToken - ) => SaveAsync(channelId, Background, extension, content, cancellationToken); + ) => SaveAsync(templateId, Background, extension, content, cancellationToken); - public Task SaveMusicAsync( - Guid channelId, - string extension, - Stream content, - CancellationToken cancellationToken - ) => SaveAsync(channelId, Music, extension, content, cancellationToken); + public void DeleteAudio(Guid templateId) => DeleteKind(templateId, Audio); - public void DeleteBackground(Guid channelId) => DeleteKind(channelId, Background); + public void DeleteBackground(Guid templateId) => DeleteKind(templateId, Background); - public void DeleteMusic(Guid channelId) => DeleteKind(channelId, Music); + public void DeleteTemplate(Guid templateId) + { + var dir = paths.BumperTemplateDir(templateId); + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } - public string? BackgroundPath(Guid channelId, string? extension) => - ResolvePath(channelId, Background, extension); + public string? AudioPath(Guid templateId, string? extension) => + ResolvePath(templateId, Audio, extension); - public string? MusicPath(Guid channelId, string? extension) => - ResolvePath(channelId, Music, extension); + public string? BackgroundPath(Guid templateId, string? extension) => + ResolvePath(templateId, Background, extension); private async Task SaveAsync( - Guid channelId, + Guid templateId, string kind, string extension, Stream content, CancellationToken cancellationToken ) { - var dir = paths.BumperChannelDir(channelId); + var dir = paths.BumperTemplateDir(templateId); Directory.CreateDirectory(dir); RemoveExisting(dir, kind); - var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension)); + var path = paths.BumperTemplateFilePath(templateId, kind, NormalizeExtension(extension)); await using var fs = File.Create(path); await content.CopyToAsync(fs, cancellationToken); } - private void DeleteKind(Guid channelId, string kind) + private void DeleteKind(Guid templateId, string kind) { - var dir = paths.BumperChannelDir(channelId); + var dir = paths.BumperTemplateDir(templateId); if (Directory.Exists(dir)) RemoveExisting(dir, kind); } - private string? ResolvePath(Guid channelId, string kind, string? extension) + private string? ResolvePath(Guid templateId, string kind, string? extension) { if (string.IsNullOrWhiteSpace(extension)) return null; - var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension)); + var path = paths.BumperTemplateFilePath(templateId, kind, NormalizeExtension(extension)); return File.Exists(path) ? path : null; } diff --git a/backend/src/TeleWave.Infrastructure/Media/FfprobeAudioProbe.cs b/backend/src/TeleWave.Infrastructure/Media/FfprobeAudioProbe.cs new file mode 100644 index 0000000..07dd402 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/FfprobeAudioProbe.cs @@ -0,0 +1,56 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Замер длительности аудиофайла через ffprobe (format.duration). Используется при загрузке звука +/// блока заставки, чтобы длина заставки шла по длине звука. +/// +public sealed class FfprobeAudioProbe(IOptions mediaOptions) : IAudioProbe +{ + private readonly MediaOptions _media = mediaOptions.Value; + + public async Task TryGetDurationAsync( + string absolutePath, + CancellationToken cancellationToken + ) + { + if (!File.Exists(absolutePath)) + return null; + + try + { + var result = await ProcessRunner.RunAsync( + _media.FfprobePath, + ["-v", "quiet", "-print_format", "json", "-show_format", absolutePath], + lowPriority: false, + cancellationToken + ); + if (result.ExitCode != 0) + return null; + + using var doc = JsonDocument.Parse(result.StdOut); + if ( + doc.RootElement.TryGetProperty("format", out var format) + && format.TryGetProperty("duration", out var durEl) + && double.TryParse( + durEl.GetString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var seconds + ) + && seconds > 0 + ) + return TimeSpan.FromSeconds(seconds); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + // Не удалось разобрать вывод ffprobe — длину не знаем. + } + + return null; + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs index 23d3c10..7d6c088 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs @@ -79,12 +79,12 @@ public sealed class MediaPathResolver } } - public string BumperChannelDir(Guid channelId) => - EnsureWithinRoot(Path.Combine(BumpersDir, channelId.ToString("N"))); + public string BumperTemplateDir(Guid templateId) => + EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"))); - /// Путь к файлу шаблона заставки (kind — «background»/«music», extension — с точкой). - public string BumperFilePath(Guid channelId, string kind, string extension) => - EnsureWithinRoot(Path.Combine(BumpersDir, channelId.ToString("N"), kind + extension)); + /// Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой). + public string BumperTemplateFilePath(Guid templateId, string kind, string extension) => + EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension)); public string OriginalPath(Guid assetId, string extension) => EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension)); diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725074209_BumperTemplates.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725074209_BumperTemplates.Designer.cs new file mode 100644 index 0000000..0507fe8 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725074209_BumperTemplates.Designer.cs @@ -0,0 +1,859 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260725074209_BumperTemplates")] + partial class BumperTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperNextLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperNowLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperOnlyBetweenDifferentShows") + .HasColumnType("boolean"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725074209_BumperTemplates.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725074209_BumperTemplates.cs new file mode 100644 index 0000000..a6f8b2b --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725074209_BumperTemplates.cs @@ -0,0 +1,216 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class BumperTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChannelJingle"); + + migrationBuilder.DropColumn( + name: "BumperAccentColor", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperBackgroundColor", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperBackgroundColor2", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperBackgroundExtension", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperDurationSeconds", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperMode", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperMusicExtension", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperTextColor", + table: "Channels"); + + migrationBuilder.RenameColumn( + name: "NextJingleIndex", + table: "Channels", + newName: "NextBumperIndex"); + + // Ревизия файлов уехала на блоки заставок — старый счётчик не переносим, стратегия по + // умолчанию Rotation (0). + migrationBuilder.DropColumn( + name: "BumperRevision", + table: "Channels"); + + migrationBuilder.AddColumn( + name: "BumperSelection", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "BumperTemplate", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + BackgroundColor = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + BackgroundColor2 = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + AccentColor = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + TextColor = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + BackgroundImageExtension = table.Column(type: "character varying(16)", maxLength: 16, nullable: true), + AudioExtension = table.Column(type: "character varying(16)", maxLength: 16, nullable: true), + AudioDurationSeconds = table.Column(type: "double precision", nullable: true), + Revision = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BumperTemplate", x => x.Id); + table.ForeignKey( + name: "FK_BumperTemplate_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BumperTemplate_ChannelId_Position", + table: "BumperTemplate", + columns: new[] { "ChannelId", "Position" }); + + // Каждому существующему каналу — дефолтный блок заставки (Position 0, без звука/фона). + migrationBuilder.Sql( + """ + INSERT INTO "BumperTemplate" + ("Id", "ChannelId", "Position", "Name", "BackgroundColor", "BackgroundColor2", + "AccentColor", "TextColor", "Revision", "CreatedAt") + SELECT gen_random_uuid(), c."Id", 0, 'Заставка 1', '0x0b1020', '0x1e293b', + '0x38bdf8', 'white', 0, now() + FROM "Channels" c; + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BumperTemplate"); + + migrationBuilder.RenameColumn( + name: "NextBumperIndex", + table: "Channels", + newName: "NextJingleIndex"); + + migrationBuilder.DropColumn( + name: "BumperSelection", + table: "Channels"); + + migrationBuilder.AddColumn( + name: "BumperRevision", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "BumperAccentColor", + table: "Channels", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "BumperBackgroundColor", + table: "Channels", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "BumperBackgroundColor2", + table: "Channels", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "BumperBackgroundExtension", + table: "Channels", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "BumperDurationSeconds", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "BumperMode", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "BumperMusicExtension", + table: "Channels", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "BumperTextColor", + table: "Channels", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "ChannelJingle", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + MediaAssetId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelJingle", x => x.Id); + table.ForeignKey( + name: "FK_ChannelJingle_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChannelJingle_ChannelId_Position", + table: "ChannelJingle", + columns: new[] { "ChannelId", "Position" }); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 3422d93..dc0c6ad 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -188,6 +188,66 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("BumperAssets"); }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => { b.Property("Id") @@ -199,36 +259,12 @@ namespace TeleWave.Infrastructure.Migrations b.Property("AdsPerBreak") .HasColumnType("integer"); - b.Property("BumperAccentColor") - .IsRequired() - .HasColumnType("text"); - - b.Property("BumperBackgroundColor") - .IsRequired() - .HasColumnType("text"); - - b.Property("BumperBackgroundColor2") - .IsRequired() - .HasColumnType("text"); - - b.Property("BumperBackgroundExtension") - .HasColumnType("text"); - - b.Property("BumperDurationSeconds") - .HasColumnType("integer"); - b.Property("BumperFont") .HasColumnType("integer"); b.Property("BumperMinIntervalMinutes") .HasColumnType("integer"); - b.Property("BumperMode") - .HasColumnType("integer"); - - b.Property("BumperMusicExtension") - .HasColumnType("text"); - b.Property("BumperNextLabel") .IsRequired() .HasColumnType("text"); @@ -240,13 +276,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("BumperOnlyBetweenDifferentShows") .HasColumnType("boolean"); - b.Property("BumperRevision") + b.Property("BumperSelection") .HasColumnType("integer"); - b.Property("BumperTextColor") - .IsRequired() - .HasColumnType("text"); - b.Property("BumpersEnabled") .HasColumnType("boolean"); @@ -270,7 +302,7 @@ namespace TeleWave.Infrastructure.Migrations b.Property("NextAdIndex") .HasColumnType("integer"); - b.Property("NextJingleIndex") + b.Property("NextBumperIndex") .HasColumnType("integer"); b.Property("Slug") @@ -307,27 +339,6 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("ChannelAd"); }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("ChannelId") - .HasColumnType("uuid"); - - b.Property("MediaAssetId") - .HasColumnType("uuid"); - - b.Property("Position") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("ChannelId", "Position"); - - b.ToTable("ChannelJingle"); - }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => { b.Property("Id") @@ -765,19 +776,19 @@ namespace TeleWave.Infrastructure.Migrations .IsRequired(); }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => { b.HasOne("TeleWave.Domain.Broadcast.Channel", null) - .WithMany("Ads") + .WithMany("BumperTemplates") .HasForeignKey("ChannelId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b => + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => { b.HasOne("TeleWave.Domain.Broadcast.Channel", null) - .WithMany("Jingles") + .WithMany("Ads") .HasForeignKey("ChannelId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -823,7 +834,7 @@ namespace TeleWave.Infrastructure.Migrations { b.Navigation("Ads"); - b.Navigation("Jingles"); + b.Navigation("BumperTemplates"); b.Navigation("Overrides"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs index 1651602..8d0246b 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs @@ -28,11 +28,11 @@ public class ChannelConfiguration : IEntityTypeConfiguration builder.Navigation(x => x.Ads).UsePropertyAccessMode(PropertyAccessMode.Field); builder - .HasMany(x => x.Jingles) + .HasMany(x => x.BumperTemplates) .WithOne() - .HasForeignKey(j => j.ChannelId) + .HasForeignKey(t => t.ChannelId) .OnDelete(DeleteBehavior.Cascade); - builder.Navigation(x => x.Jingles).UsePropertyAccessMode(PropertyAccessMode.Field); + builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field); builder .HasMany(x => x.Overrides) @@ -59,11 +59,18 @@ public class ChannelAdConfiguration : IEntityTypeConfiguration } } -public class ChannelJingleConfiguration : IEntityTypeConfiguration +public class BumperTemplateConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder builder) + public void Configure(EntityTypeBuilder builder) { builder.HasIndex(x => new { x.ChannelId, x.Position }); + builder.Property(x => x.Name).IsRequired().HasMaxLength(64); + builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32); + builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32); + builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32); + builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32); + builder.Property(x => x.BackgroundImageExtension).HasMaxLength(16); + builder.Property(x => x.AudioExtension).HasMaxLength(16); } } diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs index 98c2d8c..5362f35 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs @@ -20,6 +20,26 @@ public class SchedulePlannerTests private static Dictionary Durations(params (Guid Id, int Minutes)[] items) => items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes)); + private static readonly Guid DefaultTemplate = Guid.NewGuid(); + + /// Конфиг заставок с одним дефолтным блоком (8с), если явно не заданы блоки. + private static PlannerBumperConfig Bumper( + bool enabled, + bool onlyBetweenDifferentShows, + TimeSpan minInterval, + BumperSelection selection = BumperSelection.Rotation, + params PlannerBumperTemplate[] templates + ) => + new( + enabled, + onlyBetweenDifferentShows, + minInterval, + selection, + templates.Length == 0 + ? [new PlannerBumperTemplate(DefaultTemplate, TimeSpan.FromSeconds(8))] + : templates + ); + [Fact] public void Count_Block_ProducesConsecutiveEpisodes_BackToBack() { @@ -212,7 +232,7 @@ public class SchedulePlannerTests var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { - Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero), + Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.Zero), }; // Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2). @@ -228,6 +248,7 @@ public class SchedulePlannerTests Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор Assert.Equal(a.ShowId, first.FromShowId); Assert.Equal(b.ShowId, first.ToShowId); + Assert.Equal(DefaultTemplate, first.BumperTemplateId); Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc); // Встык: программа → заставка → программа. Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc); @@ -246,7 +267,7 @@ public class SchedulePlannerTests horizonEnd: Start.AddMinutes(50) ) with { - Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero), + Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.Zero), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0)); @@ -266,7 +287,7 @@ public class SchedulePlannerTests horizonEnd: Start.AddMinutes(50) ) with { - Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: false, MinInterval: TimeSpan.Zero), + Bumpers = Bumper(true, onlyBetweenDifferentShows: false, TimeSpan.Zero), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0)); @@ -284,12 +305,7 @@ public class SchedulePlannerTests // Два перехода в горизонте (~на 20-й и ~40-й минуте), но интервал 30 мин пропускает второй. var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { - Bumpers = new PlannerBumperConfig( - true, - TimeSpan.FromSeconds(8), - OnlyBetweenDifferentShows: true, - MinInterval: TimeSpan.FromMinutes(30) - ), + Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.FromMinutes(30)), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); @@ -298,37 +314,70 @@ public class SchedulePlannerTests } [Fact] - public void Bumpers_StaticMode_UsesJinglesFromPoolInRotation() + public void Bumpers_Rotation_CyclesTemplatesAndAdvancesCursor() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); - Guid j0 = Guid.NewGuid(), - j1 = Guid.NewGuid(); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1), (j1, 1)); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + Guid t0 = Guid.NewGuid(), + t1 = Guid.NewGuid(); - var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with + var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with { - Bumpers = new PlannerBumperConfig( + Bumpers = Bumper( true, - TimeSpan.FromSeconds(8), - OnlyBetweenDifferentShows: true, - MinInterval: TimeSpan.Zero, - Mode: BumperMode.Static, - JinglePool: [j0, j1] + onlyBetweenDifferentShows: true, + TimeSpan.Zero, + BumperSelection.Rotation, + new PlannerBumperTemplate(t0, TimeSpan.FromSeconds(8)), + new PlannerBumperTemplate(t1, TimeSpan.FromSeconds(4)) ), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); - Assert.Equal(2, bumpers.Count); - Assert.Equal([j0, j1], bumpers.Select(e => e.MediaAssetId)); // ротация пула - Assert.All(bumpers, e => Assert.Null(e.FromShowId)); // статик — не по паре шоу - Assert.Equal(2, result.NextJingleIndex); + Assert.True(bumpers.Count >= 2); + // Ротация: первый блок → t0 (8с), второй → t1 (4с). Все — плейсхолдеры по паре шоу. + Assert.Equal(t0, bumpers[0].BumperTemplateId); + Assert.Equal(TimeSpan.FromSeconds(8), bumpers[0].EndsAtUtc - bumpers[0].StartsAtUtc); + Assert.Equal(t1, bumpers[1].BumperTemplateId); + Assert.Equal(TimeSpan.FromSeconds(4), bumpers[1].EndsAtUtc - bumpers[1].StartsAtUtc); + Assert.All(bumpers, e => Assert.Equal(Guid.Empty, e.MediaAssetId)); + Assert.Equal(bumpers.Count, result.NextBumperIndex); } [Fact] - public void Bumpers_StaticMode_EmptyPool_NoBumpers() + public void Bumpers_AlwaysFirst_AlwaysUsesFirstTemplate() + { + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + Guid t0 = Guid.NewGuid(), + t1 = Guid.NewGuid(); + + var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with + { + Bumpers = Bumper( + true, + onlyBetweenDifferentShows: true, + TimeSpan.Zero, + BumperSelection.AlwaysFirst, + new PlannerBumperTemplate(t0, TimeSpan.FromSeconds(8)), + new PlannerBumperTemplate(t1, TimeSpan.FromSeconds(4)) + ), + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); + + var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); + Assert.True(bumpers.Count >= 2); + Assert.All(bumpers, e => Assert.Equal(t0, e.BumperTemplateId)); + Assert.Equal(0, result.NextBumperIndex); // курсор ротации не двигается + } + + [Fact] + public void Bumpers_NoTemplates_NoBumpers() { var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); @@ -338,11 +387,10 @@ public class SchedulePlannerTests { Bumpers = new PlannerBumperConfig( true, - TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero, - Mode: BumperMode.Static, - JinglePool: [] + Selection: BumperSelection.Rotation, + Templates: [] ), }; @@ -351,35 +399,6 @@ public class SchedulePlannerTests Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); } - [Fact] - public void Bumpers_BothMode_AlternatesDynamicAndStatic() - { - var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); - var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); - var j0 = Guid.NewGuid(); - var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1)); - - // Три перехода: динамика, джингл, динамика. - var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with - { - Bumpers = new PlannerBumperConfig( - true, - TimeSpan.FromSeconds(8), - OnlyBetweenDifferentShows: true, - MinInterval: TimeSpan.Zero, - Mode: BumperMode.Both, - JinglePool: [j0] - ), - }; - - var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); - - var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); - Assert.True(bumpers.Count >= 2); - Assert.Equal(Guid.Empty, bumpers[0].MediaAssetId); // первый — динамический (плейсхолдер) - Assert.Equal(j0, bumpers[1].MediaAssetId); // второй — статичный джингл - } - [Fact] public void Bumpers_Disabled_ProduceNoBumperEntries() { @@ -389,7 +408,7 @@ public class SchedulePlannerTests var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with { - Bumpers = new PlannerBumperConfig(false, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero), + Bumpers = Bumper(false, onlyBetweenDifferentShows: true, TimeSpan.Zero), }; var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 4987efc..0dffc16 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -1,15 +1,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { type ReactNode, useEffect, useState } from 'react' +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 { HttpError } from '@/shared/api/client' +import { getAccessToken, HttpError } from '@/shared/api/client' import type { AdInsertion, BlockMode, BumperFont, - BumperMode, + BumperSelection, BumperSettings, + BumperTemplateDto, ChannelShowDto, OverrideMode, ScheduleEntryDto, @@ -24,23 +26,26 @@ import { toast } from '@/shared/ui/toast-store' import { listMedia } from '@/features/admin/media/api' import { listShows } from '@/features/admin/shows/api' import { + addBumperTemplate, addChannelAd, - addChannelJingle, addChannelShow, - clearBumperBackground, - clearBumperMusic, + bumperPreviewPlaylistUrl, + clearBumperTemplateAudio, + clearBumperTemplateBackground, createOverride, deleteOverride, getChannel, getSchedule, regenerateSchedule, + removeBumperTemplate, removeChannelAd, - removeChannelJingle, removeChannelShow, + renderBumperPreview, + updateBumperTemplate, updateChannelSettings, updateChannelShow, - uploadBumperBackground, - uploadBumperMusic, + uploadBumperTemplateAudio, + uploadBumperTemplateBackground, } from './api' function formatTime(iso: string) { @@ -117,6 +122,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) { + + {/* Шоу канала */} - {/* Джинглы-отбивки (статичные заставки) */} - -

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

- !channel.jingles.some((j) => j.mediaAssetId === a.id)) - .map((a) => ({ id: a.id, name: a.originalFileName }))} - onAdded={invalidate} - onError={onError} - /> -
    - {channel.jingles.map((j) => ( -
  • - {j.assetName ?? '—'} - - removeChannelJingle(channelId, j.id).then(invalidate).catch(onError) - } - /> -
  • - ))} - {channel.jingles.length === 0 && ( -
  • {t('admin.channels.noJingles')}
  • - )} -
-
- {/* Override'ы / марафоны */} (channel.adInsertion) const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak) - const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled) - const [bumper, setBumper] = useState(channel.bumper) const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '') - const setBumperField = (key: K, value: BumperSettings[K]) => - setBumper((prev) => ({ ...prev, [key]: value })) - useEffect(() => { setName(channel.name) setIsEnabled(channel.isEnabled) setAdInsertion(channel.adInsertion) setAdsPerBreak(channel.adsPerBreak) - setBumpersEnabled(channel.bumpersEnabled) - setBumper(channel.bumper) setFillerAssetId(channel.fillerAssetId ?? '') }, [channel]) @@ -317,8 +289,9 @@ function SettingsCard({ isEnabled, adInsertion, adsPerBreak, - bumpersEnabled, - bumper, + // Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений. + bumpersEnabled: channel.bumpersEnabled, + bumper: channel.bumper, fillerAssetId: fillerAssetId || null, }), onSuccess: () => { @@ -384,31 +357,6 @@ function SettingsCard({ /> {t('admin.channels.enabledLabel')} - - {bumpersEnabled && ( -
- -
- )}
+
+ + {/* Блоки заставок */} +
+

{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 [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 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 ( +
+
+
+ {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 && ( + + )} +
+ +
+
+ + setName(e.target.value)} /> +
+ {colorFields.map(({ key, label }) => ( +
+ +
+ + setColors((c) => ({ ...c, [key]: e.target.value }))} + /> +
+
+ ))} +
+ +
+ + +
+ +
+ +
+ +
+ +
) } +function BumperPreviewPlayer({ + channelId, + templateId, + onError, +}: { + channelId: string + templateId: string + onError: (e: unknown) => void +}) { + const { t } = useTranslation() + const videoRef = useRef(null) + const [ready, setReady] = useState(false) + const [bust, setBust] = useState(0) + + const render = useMutation({ + mutationFn: () => renderBumperPreview(channelId, templateId), + onSuccess: () => { + setBust(Date.now()) + setReady(true) + }, + onError, + }) + + // Грузим отрендеренный превью-плейлист через hls.js, добавляя Bearer-токен (admin-роут под JWT). + useEffect(() => { + if (!ready) return + const video = videoRef.current + if (!video) return + const src = `${bumperPreviewPlaylistUrl(channelId, templateId)}?t=${bust}` + 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() + } + }, [ready, bust, channelId, templateId]) + + return ( + <> +
+ + + {t('admin.channels.bumperPreviewHint')} + +
+ {ready && ( +