From 0cfc72166a55af214aaa9f67d7d217c3b4a33d27 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 00:45:22 +0300 Subject: [PATCH] Add TV bumpers functionality: introduce configuration options for bumpers in .env.example and appsettings.json, enhance ChannelEndpoints to manage jingles and bumper assets, and update Channel and ScheduleEntry models to support bumper logic. Implement validation for bumper settings and integrate bumper handling in scheduling logic. --- .env.example | 10 + Dockerfile | 5 +- .../TeleWave.Api/Common/ResultExtensions.cs | 2 +- .../Endpoints/ChannelEndpoints.cs | 169 ++++ backend/src/TeleWave.Api/appsettings.json | 7 + .../AddChannelJingleCommand.cs | 7 + .../AddChannelJingleCommandHandler.cs | 35 + .../ClearBumperBackgroundCommand.cs | 6 + .../ClearBumperBackgroundCommandHandler.cs | 29 + .../SetBumperBackgroundCommand.cs | 7 + .../SetBumperBackgroundCommandHandler.cs | 26 + .../BumperMusic/ClearBumperMusicCommand.cs | 6 + .../ClearBumperMusicCommandHandler.cs | 29 + .../BumperMusic/SetBumperMusicCommand.cs | 7 + .../SetBumperMusicCommandHandler.cs | 26 + .../Broadcast/Bumpers/BumperOptions.cs | 22 + .../Broadcast/ChannelDtos.cs | 21 + .../Broadcast/ChannelErrors.cs | 15 + .../GetChannel/GetChannelQueryHandler.cs | 34 +- .../RemoveChannelJingleCommand.cs | 7 + .../RemoveChannelJingleCommandHandler.cs | 26 + .../Broadcast/Scheduling/ScheduleGenerator.cs | 295 ++++++- .../UpdateChannelSettingsCommand.cs | 17 + .../UpdateChannelSettingsCommandHandler.cs | 14 + .../UpdateChannelSettingsCommandValidator.cs | 24 +- .../Common/Interfaces/IAppDbContext.cs | 1 + .../Common/Interfaces/IBumperRenderer.cs | 47 ++ .../Interfaces/IBumperTemplateStorage.cs | 31 + .../ListMedia/ListMediaAssetsQueryHandler.cs | 5 +- .../TeleWave.Domain/Broadcast/BumperAsset.cs | 40 + .../TeleWave.Domain/Broadcast/BumperFont.cs | 11 + .../TeleWave.Domain/Broadcast/BumperMode.cs | 14 + .../src/TeleWave.Domain/Broadcast/Channel.cs | 145 ++++ .../Broadcast/ChannelJingle.cs | 22 + .../Broadcast/ScheduleEntry.cs | 19 + .../Broadcast/ScheduleEntryKind.cs | 3 + .../Broadcast/Scheduling/SchedulePlanner.cs | 101 ++- .../Scheduling/SchedulePlannerModels.cs | 31 +- .../src/TeleWave.Domain/Media/MediaAsset.cs | 19 + .../src/TeleWave.Domain/Media/MediaSource.cs | 4 + .../DependencyInjection.cs | 4 + .../Media/BumperTemplateStorage.cs | 78 ++ .../Media/FfmpegBumperRenderer.cs | 251 ++++++ .../Media/MediaPathResolver.cs | 12 + .../20260724204325_AddBumpers.Designer.cs | 713 ++++++++++++++++ .../Migrations/20260724204325_AddBumpers.cs | 54 ++ ...24210635_ChannelBumperSettings.Designer.cs | 749 ++++++++++++++++ .../20260724210635_ChannelBumperSettings.cs | 128 +++ ...24212853_BumperFilesAndJingles.Designer.cs | 796 ++++++++++++++++++ .../20260724212853_BumperFilesAndJingles.cs | 100 +++ .../Migrations/AppDbContextModelSnapshot.cs | 125 ++- .../Persistence/AppDbContext.cs | 1 + .../BumperAssetConfiguration.cs | 21 + .../Configurations/ChannelConfiguration.cs | 15 + .../Broadcast/SchedulePlannerTests.cs | 194 +++++ .../features/admin/channels/ChannelDetail.tsx | 352 ++++++++ frontend/src/features/admin/channels/api.ts | 59 +- .../src/features/admin/shows/ShowDetail.tsx | 21 +- frontend/src/shared/api/types.ts | 30 +- frontend/src/shared/lib/i18n.ts | 64 ++ 60 files changed, 5073 insertions(+), 33 deletions(-) create mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/Bumpers/BumperOptions.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/BumperFont.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/BumperMode.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/ChannelJingle.cs create mode 100644 backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs create mode 100644 backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724204325_AddBumpers.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724204325_AddBumpers.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724210635_ChannelBumperSettings.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724210635_ChannelBumperSettings.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724212853_BumperFilesAndJingles.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724212853_BumperFilesAndJingles.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/BumperAssetConfiguration.cs diff --git a/.env.example b/.env.example index 418742a..bc0f181 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,16 @@ Media__NormalizeLoudness=true # Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить). Media__LoudnessTargetLufs=-16 +# ── ТВ-заставки «Сейчас/Далее» (bumpers) ─────────────────────────────────── +# Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность, +# интервал, только-между-разными) настраиваются на каждом канале в админке. +# Bumpers__Width=1280 +# Bumpers__Height=720 +# Bumpers__FontFileSans=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf +# Bumpers__FontFileSerif=/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf +# Меняй при правке ЛОГИКИ рендера, чтобы пересобрать уже отрендеренные заставки. +# Bumpers__TemplateVersion=1 + # ── ASP.NET Core ────────────────────────────────────────────────────────── ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_HTTP_PORTS=8080 diff --git a/Dockerfile b/Dockerfile index 166f7dd..705a372 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,8 +26,9 @@ RUN dotnet publish backend/src/TeleWave.Api/TeleWave.Api.csproj -c Release -o /a # ── Stage 3: runtime ────────────────────────────────────────────────────── FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app -# curl — для HEALTHCHECK; ffmpeg — нормализация и нарезка медиа при загрузке (см. Media__FfmpegPath). -RUN apt-get update && apt-get install -y --no-install-recommends curl ffmpeg \ +# curl — для HEALTHCHECK; ffmpeg — нормализация и нарезка медиа при загрузке (см. Media__FfmpegPath); +# fonts-dejavu-core — кириллический TTF для текста на ТВ-заставках (drawtext, см. Bumpers__FontFile). +RUN apt-get update && apt-get install -y --no-install-recommends curl ffmpeg fonts-dejavu-core \ && rm -rf /var/lib/apt/lists/* ENV ASPNETCORE_ENVIRONMENT=Production \ ASPNETCORE_HTTP_PORTS=8080 diff --git a/backend/src/TeleWave.Api/Common/ResultExtensions.cs b/backend/src/TeleWave.Api/Common/ResultExtensions.cs index 5b8e865..3836c75 100644 --- a/backend/src/TeleWave.Api/Common/ResultExtensions.cs +++ b/backend/src/TeleWave.Api/Common/ResultExtensions.cs @@ -10,7 +10,7 @@ public static class ResultExtensions public static IResult ToHttpResult(this Result result) => result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error); - private static IResult ToProblem(Error error) + public static IResult ToProblem(this Error error) { var statusCode = error.Type switch { diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index 37aefc1..27c4319 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -2,7 +2,10 @@ 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.CreateChannel; using TeleWave.Application.Broadcast.CreateOverride; using TeleWave.Application.Broadcast.DeleteOverride; @@ -11,9 +14,11 @@ 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; @@ -49,6 +54,26 @@ public static class ChannelEndpoints .MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd) .Produces(StatusCodes.Status204NoContent); + admin + .MapPost("/{id:guid}/jingles", AddJingle) + .Produces(StatusCodes.Status201Created); + admin + .MapDelete("/{id:guid}/jingles/{channelJingleId:guid}", RemoveJingle) + .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) + .Produces(StatusCodes.Status204NoContent); + admin .MapPost("/{id:guid}/overrides", CreateOverride) .Produces(StatusCodes.Status201Created); @@ -109,6 +134,8 @@ public static class ChannelEndpoints body.IsEnabled, body.AdInsertion, body.AdsPerBreak, + body.BumpersEnabled, + body.Bumper, body.FillerAssetId ), cancellationToken @@ -189,6 +216,110 @@ public static class ChannelEndpoints return result.ToHttpResult(); } + private static async Task AddJingle( + Guid id, + AddChannelJingleBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new AddChannelJingleCommand(id, body.MediaAssetId), + cancellationToken + ); + return result.IsSuccess + ? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value)) + : result.ToHttpResult(); + } + + private static async Task RemoveJingle( + Guid id, + Guid channelJingleId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new RemoveChannelJingleCommand(id, channelJingleId), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task UploadBackground( + Guid id, + string fileName, + HttpRequest request, + IBumperTemplateStorage storage, + ISender sender, + CancellationToken cancellationToken + ) + { + if (ResolveBumperExtension(fileName, request, BumperFiles.BackgroundExtensions) is not { } ext) + return ChannelErrors.InvalidBumperFile.ToProblem(); + + await storage.SaveBackgroundAsync(id, ext, request.Body, cancellationToken); + + var result = await sender.Send(new SetBumperBackgroundCommand(id, ext), cancellationToken); + if (!result.IsSuccess) + storage.DeleteBackground(id); + return result.ToHttpResult(); + } + + private static async Task ClearBackground( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ClearBumperBackgroundCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UploadMusic( + Guid id, + string fileName, + HttpRequest request, + IBumperTemplateStorage storage, + 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(); + } + + private static async Task ClearMusic( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ClearBumperMusicCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + /// Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает + /// нормализованное расширение (с точкой, нижний регистр) или null при отказе. + private static string? ResolveBumperExtension( + string fileName, + HttpRequest request, + IReadOnlySet allowedExtensions + ) + { + if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null) + return null; + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + return allowedExtensions.Contains(ext) ? ext : null; + } + private static async Task CreateOverride( Guid id, CreateOverrideBody body, @@ -258,6 +389,8 @@ public sealed record UpdateChannelSettingsBody( bool IsEnabled, AdInsertion AdInsertion, int AdsPerBreak, + bool BumpersEnabled, + BumperSettingsInput Bumper, Guid? FillerAssetId ); @@ -272,6 +405,42 @@ public sealed record UpdateChannelShowBody( public sealed record AddChannelAdBody(Guid MediaAssetId); +public sealed record AddChannelJingleBody(Guid MediaAssetId); + +/// Ограничения на загружаемые файлы заставки (фон/музыка). +internal static class BumperFiles +{ + public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ + + public static readonly IReadOnlySet BackgroundExtensions = new HashSet( + StringComparer.OrdinalIgnoreCase + ) + { + ".jpg", + ".jpeg", + ".png", + ".webp", + ".bmp", + ".gif", + ".mp4", + ".mov", + ".mkv", + ".webm", + }; + + public static readonly IReadOnlySet MusicExtensions = new HashSet( + StringComparer.OrdinalIgnoreCase + ) + { + ".mp3", + ".m4a", + ".aac", + ".ogg", + ".opus", + ".wav", + }; +} + public sealed record CreateOverrideBody( OverrideMode Mode, DateTimeOffset StartsAtUtc, diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json index 051c009..501970f 100644 --- a/backend/src/TeleWave.Api/appsettings.json +++ b/backend/src/TeleWave.Api/appsettings.json @@ -34,6 +34,13 @@ "NormalizeLoudness": true, "LoudnessTargetLufs": -16 }, + "Bumpers": { + "Width": 1280, + "Height": 720, + "FontFileSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "FontFileSerif": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf", + "TemplateVersion": 1 + }, "Serilog": { "Using": [ "Serilog.Sinks.Console" ], "MinimumLevel": { diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommand.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommand.cs new file mode 100644 index 0000000..60b8f8a --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommand.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..9bac9cc --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/AddChannelJingle/AddChannelJingleCommandHandler.cs @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..7347289 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommand.cs @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..0769616 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/BumperBackground/ClearBumperBackgroundCommandHandler.cs @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..d68b1ea --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommand.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..bdf767c --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/BumperBackground/SetBumperBackgroundCommandHandler.cs @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..dbb6926 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommand.cs @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..cbfb402 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/BumperMusic/ClearBumperMusicCommandHandler.cs @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..b464f71 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommand.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..88de9aa --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/BumperMusic/SetBumperMusicCommandHandler.cs @@ -0,0 +1,26 @@ +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/BumperOptions.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperOptions.cs new file mode 100644 index 0000000..9906786 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperOptions.cs @@ -0,0 +1,22 @@ +namespace TeleWave.Application.Broadcast.Bumpers; + +/// +/// Инфраструктурные настройки рендера ТВ-заставок (общие для всех каналов): разрешение, пути к +/// шрифтам, версия шаблона. Оформление и правила (цвета, подписи, длительность, интервал) задаются +/// на каждом канале — см. Channel.UpdateBumperSettings. +/// +public sealed class BumperOptions +{ + public const string SectionName = "Bumpers"; + + public int Width { get; init; } = 1280; + public int Height { get; init; } = 720; + + /// Пути к TTF-шрифтам с кириллицей внутри контейнера (см. Dockerfile, fonts-dejavu-core). + public string FontFileSans { get; init; } = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"; + public string FontFileSerif { get; init; } = "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"; + + /// Версия шаблона рендера — входит в кэш-ключ заставки; меняй при правке ЛОГИКИ рендера + /// (не оформления канала), чтобы пересобрать уже отрендеренные заставки. + public int TemplateVersion { get; init; } = 1; +} diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index 3669374..8082c90 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -17,6 +17,8 @@ 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( @@ -27,6 +29,22 @@ 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, + bool HasBackground, + bool HasMusic +); + public sealed record ChannelDto( Guid Id, string Name, @@ -34,8 +52,11 @@ public sealed record ChannelDto( bool IsEnabled, AdInsertion AdInsertion, int AdsPerBreak, + bool BumpersEnabled, + BumperSettingsDto Bumper, 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 6bba189..8248ce4 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs @@ -36,11 +36,26 @@ public static class ChannelErrors "Реклама не найдена в пуле канала." ); + public static readonly Error JingleAlreadyAdded = Error.Conflict( + "Channels.JingleAlreadyAdded", + "Этот ролик уже в пуле джинглов канала." + ); + + public static readonly Error JingleNotFound = Error.NotFound( + "Channels.JingleNotFound", + "Джингл не найден в пуле канала." + ); + public static readonly Error AssetNotFound = Error.NotFound( "Channels.AssetNotFound", "Медиа-ассет не найден." ); + public static readonly Error InvalidBumperFile = Error.Validation( + "Channels.InvalidBumperFile", + "Недопустимый файл заставки (формат или размер)." + ); + public static readonly Error OverrideNotFound = Error.NotFound( "Channels.OverrideNotFound", "Override не найден." diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 5ffc78d..744369d 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -31,9 +31,12 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) .Select(s => new { s.Id, s.Name }) .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); - var adAssetIds = channel.Ads.Select(a => a.MediaAssetId).ToList(); + var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId) + .Concat(channel.Jingles.Select(j => j.MediaAssetId)) + .Distinct() + .ToList(); var assetNames = await dbContext.MediaAssets.AsNoTracking() - .Where(a => adAssetIds.Contains(a.Id)) + .Where(a => poolAssetIds.Contains(a.Id)) .Select(a => new { a.Id, a.OriginalFileName }) .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); @@ -62,6 +65,16 @@ 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 + )) + .ToList(); + var overrides = channel.Overrides .OrderBy(o => o.StartsAtUtc) .Select(o => new ProgrammingOverrideDto( @@ -83,9 +96,26 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) channel.IsEnabled, channel.AdInsertion, 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.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 new file mode 100644 index 0000000..ff9c1ca --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommand.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..d04486c --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/RemoveChannelJingle/RemoveChannelJingleCommandHandler.cs @@ -0,0 +1,26 @@ +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 845fc4c..8c690eb 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -1,6 +1,11 @@ +using System.Security.Cryptography; +using System.Text; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Streaming; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Media; @@ -11,14 +16,23 @@ namespace TeleWave.Application.Broadcast.Scheduling; /// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый /// , материализует записи и двигает курсоры. Используется фоновым /// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала). +/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) и подставляются +/// как обычные ассеты. /// public sealed class ScheduleGenerator( IAppDbContext dbContext, IRandomSource random, - IOptions options + IBumperRenderer bumperRenderer, + IBumperTemplateStorage bumperStorage, + IOptions options, + IOptions bumperOptions, + IOptions streamingOptions, + ILogger logger ) { private readonly SchedulerOptions _options = options.Value; + private readonly BumperOptions _bumper = bumperOptions.Value; + private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); /// /// Достраивает (или, при , перестраивает будущий хвост) расписание @@ -34,6 +48,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.Overrides) .ThenInclude(o => o.Shows) .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken); @@ -71,22 +86,46 @@ public sealed class ScheduleGenerator( return 0; } + var showNames = await LoadShowNamesAsync(channel, cancellationToken); var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken); var result = SchedulePlanner.Plan(input, random); + // Рендерим/достаём из кэша ассеты заставок для всех переходов плана. + var bumperAssets = await ResolveBumperAssetsAsync( + channel, + result.Entries, + showNames, + cancellationToken + ); + + var added = 0; foreach (var entry in result.Entries) { - var scheduleEntry = entry.Kind == ScheduleEntryKind.Program - ? ScheduleEntry.Program( + ScheduleEntry? scheduleEntry = entry.Kind switch + { + ScheduleEntryKind.Program => ScheduleEntry.Program( channel.Id, entry.MediaAssetId, entry.StartsAtUtc, entry.EndsAtUtc, entry.ShowId!.Value, entry.EpisodeIndex!.Value - ) - : ScheduleEntry.Ad(channel.Id, entry.MediaAssetId, entry.StartsAtUtc, entry.EndsAtUtc); + ), + ScheduleEntryKind.Ad => ScheduleEntry.Ad( + channel.Id, + entry.MediaAssetId, + entry.StartsAtUtc, + entry.EndsAtUtc + ), + ScheduleEntryKind.Bumper => BuildBumperEntry(channel.Id, entry, bumperAssets), + _ => null, + }; + + if (scheduleEntry is null) + continue; + dbContext.ScheduleEntries.Add(scheduleEntry); + added++; } foreach (var channelShow in channel.Shows) @@ -94,9 +133,233 @@ public sealed class ScheduleGenerator( channelShow.SetNextEpisodeIndex(idx); channel.SetNextAdIndex(result.NextAdIndex); + channel.SetNextJingleIndex(result.NextJingleIndex); await dbContext.SaveChangesAsync(cancellationToken); - return result.Entries.Count; + return added; + } + + private static ScheduleEntry? BuildBumperEntry( + Guid channelId, + PlannedEntry entry, + IReadOnlyDictionary<(Guid, Guid), 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) + ) + // Заставку не удалось отрендерить — пропускаем запись (слот заполнит филлер/следующая + // программа). Планировщик уже учёл её длину, поэтому небольшой зазор допустим. + return null; + + return ScheduleEntry.Bumper(channelId, assetId, entry.StartsAtUtc, entry.EndsAtUtc, entry.ToShowId); + } + + /// + /// Для каждой уникальной пары «из→в» из запланированных заставок возвращает id готового + /// ассета-заставки: из кэша () либо свежесгенерированного. + /// + 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)) + .Distinct() + .ToList(); + if (pairs.Count == 0) + return result; + + var fromIds = pairs.Select(p => p.From).Distinct().ToList(); + var toIds = pairs.Select(p => p.To).Distinct().ToList(); + + // Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить). + var cached = await dbContext.BumperAssets.AsNoTracking() + .Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId)) + .Select(b => new + { + b.FromShowId, + b.ToShowId, + b.Signature, + b.MediaAssetId, + }) + .ToListAsync(cancellationToken); + + var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList(); + var readyAssetIds = await dbContext.MediaAssets.AsNoTracking() + .Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready) + .Select(a => a.Id) + .ToListAsync(cancellationToken); + var readySet = readyAssetIds.ToHashSet(); + + foreach (var pair in pairs) + { + var fromName = showNames.GetValueOrDefault(pair.From, "…"); + var toName = showNames.GetValueOrDefault(pair.To, "…"); + var signature = ComputeSignature(fromName, toName, styleSignature); + + var hit = cached.FirstOrDefault(c => + c.FromShowId == pair.From + && c.ToShowId == pair.To + && c.Signature == signature + && readySet.Contains(c.MediaAssetId) + ); + if (hit is not null) + { + result[pair] = hit.MediaAssetId; + continue; + } + + try + { + var assetId = await RenderBumperAsync( + channel, + pair.From, + pair.To, + fromName, + toName, + signature, + cancellationToken + ); + result[pair] = assetId; + } + catch (Exception ex) + { + logger.LogError( + ex, + "Не удалось отрендерить заставку {From} → {To}", + fromName, + toName + ); + } + } + + return result; + } + + private async Task RenderBumperAsync( + Channel channel, + Guid fromShowId, + Guid toShowId, + string fromName, + string toName, + string signature, + CancellationToken cancellationToken + ) + { + var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}"); + var render = await bumperRenderer.RenderAsync( + asset.Id, + BuildSpec(channel, fromName, toName), + cancellationToken + ); + + asset.MarkReady( + render.Duration, + render.SegmentSeconds, + render.SegmentCount, + render.Width, + render.Height, + "h264", + "aac", + render.RelativePath + ); + + dbContext.MediaAssets.Add(asset); + dbContext.BumperAssets.Add( + BumperAsset.Create(fromShowId, toShowId, signature, asset.Id) + ); + return asset.Id; + } + + private BumperRenderSpec BuildSpec(Channel channel, string fromName, string toName) => + new( + AlignedBumperDuration(channel), + _bumper.Width, + _bumper.Height, + channel.BumperBackgroundColor, + channel.BumperBackgroundColor2, + channel.BumperAccentColor, + channel.BumperTextColor, + FontPath(channel.BumperFont), + channel.BumperNowLabel, + fromName, + channel.BumperNextLabel, + toName, + bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension), + bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension) + ); + + private string FontPath(BumperFont font) => + font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; + + /// Длительность заставки канала, выровненная вверх до кратности сегменту. + private int AlignedBumperDuration(Channel channel) + { + var requested = Math.Max(_segmentSeconds, channel.BumperDurationSeconds); + return (int)(Math.Ceiling((double)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(string fromName, string toName, string styleSignature) + { + var raw = string.Join('', fromName, toName, styleSignature); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexString(hash); + } + + private async Task> LoadShowNamesAsync( + Channel channel, + CancellationToken cancellationToken + ) + { + var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList(); + if (showIds.Count == 0) + return new Dictionary(); + + return await dbContext.Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Name }) + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); } private async Task BuildInputAsync( @@ -122,6 +385,7 @@ 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(); @@ -162,6 +426,12 @@ public sealed class ScheduleGenerator( .Where(durations.ContainsKey) .ToList(); + var jinglePool = channel.Jingles + .OrderBy(j => j.Position) + .Select(j => j.MediaAssetId) + .Where(durations.ContainsKey) + .ToList(); + var overrides = channel.Overrides .Select(o => new PlannerOverride( o.StartsAtUtc, @@ -171,6 +441,15 @@ public sealed class ScheduleGenerator( )) .ToList(); + var bumpers = new PlannerBumperConfig( + channel.BumpersEnabled, + TimeSpan.FromSeconds(AlignedBumperDuration(channel)), + channel.BumperOnlyBetweenDifferentShows, + TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes), + channel.BumperMode, + jinglePool + ); + return new PlannerInput( channel.Id, channel.AdInsertion, @@ -181,7 +460,9 @@ public sealed class ScheduleGenerator( durations, overrides, startTime, - horizonEnd + horizonEnd, + bumpers, + channel.NextJingleIndex ); } } diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs index 8b38c1d..e4ab7f8 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs @@ -10,5 +10,22 @@ public sealed record UpdateChannelSettingsCommand( bool IsEnabled, AdInsertion AdInsertion, int AdsPerBreak, + bool BumpersEnabled, + BumperSettingsInput Bumper, Guid? FillerAssetId ) : ICommand; + +/// Оформление и правила ТВ-заставок канала (см. 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 +); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs index 09173d8..e9f4e2f 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs @@ -32,8 +32,22 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) command.IsEnabled, command.AdInsertion, command.AdsPerBreak, + command.BumpersEnabled, 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 + ); return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs index 661c74e..242e290 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs @@ -1,13 +1,35 @@ +using System.Text.RegularExpressions; using FluentValidation; namespace TeleWave.Application.Broadcast.UpdateChannelSettings; -public sealed class UpdateChannelSettingsCommandValidator +public sealed partial class UpdateChannelSettingsCommandValidator : AbstractValidator { public 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/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs index f48c29b..9988314 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -13,6 +13,7 @@ public interface IAppDbContext DbSet Shows { get; } DbSet Channels { get; } DbSet ScheduleEntries { get; } + DbSet BumperAssets { get; } Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs new file mode 100644 index 0000000..ba40808 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs @@ -0,0 +1,47 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Полная спецификация одной заставки для рендера: оформление канала + подписи + названия шоу. +/// уже выровнена на длину сегмента (готовит оркестратор), а +/// — абсолютный путь к TTF внутри контейнера. +/// +public sealed record BumperRenderSpec( + int DurationSeconds, + int Width, + int Height, + string BackgroundColor, + string BackgroundColor2, + string AccentColor, + string TextColor, + string FontFile, + string NowLabel, + string NowTitle, + string NextLabel, + string NextTitle, + string? BackgroundFile = null, + string? MusicFile = null +); + +/// Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки. +public sealed record BumperRenderResult( + TimeSpan Duration, + int SegmentSeconds, + int SegmentCount, + int Width, + int Height, + string RelativePath +); + +/// +/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + текст +/// «Сейчас/Далее» + джингл) по и режет его на HLS-сегменты в +/// assets/{assetId} — так же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы. +/// +public interface IBumperRenderer +{ + Task RenderAsync( + Guid assetId, + BumperRenderSpec spec, + CancellationToken cancellationToken + ); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs new file mode 100644 index 0000000..ed93fff --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs @@ -0,0 +1,31 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Хранилище сырых файлов шаблона заставки канала (фон и музыка) под bumpers/{channelId}. В отличие +/// от обычных ассетов эти файлы НЕ режутся на HLS — они подаются как входы в рендер заставки. +/// +public interface IBumperTemplateStorage +{ + Task SaveBackgroundAsync( + Guid channelId, + string extension, + Stream content, + CancellationToken cancellationToken + ); + + Task SaveMusicAsync( + Guid channelId, + string extension, + Stream content, + CancellationToken cancellationToken + ); + + void DeleteBackground(Guid channelId); + void DeleteMusic(Guid channelId); + + /// Абсолютный путь к загруженному фону или null (нет расширения / файл отсутствует). + string? BackgroundPath(Guid channelId, string? extension); + + /// Абсолютный путь к загруженной музыке или null. + string? MusicPath(Guid channelId, string? extension); +} diff --git a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs index 486d3df..6e49a3d 100644 --- a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Domain.Media; namespace TeleWave.Application.Media.ListMedia; @@ -13,7 +14,9 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext) CancellationToken cancellationToken ) { - var q = dbContext.MediaAssets.AsNoTracking(); + // Сгенерированные системой ассеты (ТВ-заставки) не показываем в библиотеке медиа. + var q = dbContext.MediaAssets.AsNoTracking() + .Where(x => x.Source != MediaSource.Generated); if (query.Statuses.Count > 0) q = q.Where(x => query.Statuses.Contains(x.Status)); diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs new file mode 100644 index 0000000..a10565e --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs @@ -0,0 +1,40 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Кэш отрендеренной ТВ-заставки перехода. Один сгенерированный на +/// уникальную комбинацию () при данной +/// (хэш названий шоу и версии шаблона). Переиспользуется между днями и +/// каналами; при смене названий/шаблона меняется и рендерится новый ассет. +/// +public class BumperAsset +{ + public Guid Id { get; private set; } + public Guid FromShowId { get; private set; } + public Guid ToShowId { get; private set; } + + /// Хэш входных данных рендера (названия «из/в» + версия шаблона). + public string Signature { get; private set; } = string.Empty; + + /// Сгенерированный ассет-заставка (нарезан в assets/{id}, статус Ready). + public Guid MediaAssetId { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + private BumperAsset() { } + + public static BumperAsset Create( + Guid fromShowId, + Guid toShowId, + string signature, + Guid mediaAssetId + ) => + new() + { + Id = Guid.NewGuid(), + FromShowId = fromShowId, + ToShowId = toShowId, + Signature = signature, + MediaAssetId = mediaAssetId, + CreatedAt = DateTimeOffset.UtcNow, + }; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperFont.cs b/backend/src/TeleWave.Domain/Broadcast/BumperFont.cs new file mode 100644 index 0000000..6b10af9 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperFont.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Domain.Broadcast; + +/// Гарнитура текста на заставке. Маппится на конкретный TTF в конфигурации рендерера. +public enum BumperFont +{ + /// Гротеск (DejaVu Sans Bold) — по умолчанию. + Sans, + + /// Антиква (DejaVu Serif Bold). + Serif, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperMode.cs b/backend/src/TeleWave.Domain/Broadcast/BumperMode.cs new file mode 100644 index 0000000..908b0ec --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BumperMode.cs @@ -0,0 +1,14 @@ +namespace TeleWave.Domain.Broadcast; + +/// Какие заставки вставлять на переходах. +public enum BumperMode +{ + /// Только динамические «Сейчас/Далее», отрисованные по оформлению канала. + Dynamic, + + /// Только готовые ролики-джинглы из пула канала (по кругу). + Static, + + /// И то, и другое — чередуя на соседних переходах. + Both, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs index c28fdb9..c762136 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -10,6 +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(); public Guid Id { get; private set; } public string Name { get; private set; } = string.Empty; @@ -22,6 +23,49 @@ public class Channel public AdInsertion AdInsertion { get; private set; } public int AdsPerBreak { get; private set; } + /// Вставлять ли ТВ-заставки на переходах между разными шоу. + public bool BumpersEnabled { get; private set; } + + /// Какие заставки вставлять: динамические «Сейчас/Далее», статичные джинглы или оба. + public BumperMode BumperMode { get; private set; } + + /// Расширение загруженного фона (с точкой) или null — тогда синтезируется градиент. + public string? BumperBackgroundExtension { get; private set; } + + /// Расширение загруженной музыки (с точкой) или null — тогда синтезируется джингл. + public string? BumperMusicExtension { 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; + + /// Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе). + public int BumperMinIntervalMinutes { get; private set; } + + /// Ставить заставку только на смене шоу (иначе — и внутри марафона одного шоу). + 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 = "ДАЛЕЕ"; + /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка). public Guid? FillerAssetId { get; private set; } @@ -36,6 +80,9 @@ public class Channel public IReadOnlyList Ads => _ads; public IReadOnlyList Overrides => _overrides; + /// Пул джинглов-отбивок; порядок ротации — по . + public IReadOnlyList Jingles => _jingles; + private Channel() { } public static Channel Create(string name, string slug, DateTimeOffset epochUtc) => @@ -48,6 +95,22 @@ public class Channel EpochUtc = epochUtc, 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, + BumperFont = BumperFont.Sans, + BumperNowLabel = DefaultNowLabel, + BumperNextLabel = DefaultNextLabel, + BumperMinIntervalMinutes = 0, + BumperOnlyBetweenDifferentShows = true, NextAdIndex = 0, CreatedAt = DateTimeOffset.UtcNow, }; @@ -57,6 +120,7 @@ public class Channel bool isEnabled, AdInsertion adInsertion, int adsPerBreak, + bool bumpersEnabled, Guid? fillerAssetId ) { @@ -64,9 +128,90 @@ public class Channel IsEnabled = isEnabled; AdInsertion = adInsertion; AdsPerBreak = adsPerBreak; + BumpersEnabled = bumpersEnabled; 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 + ) + { + 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; + } + + /// Отметить загруженный фон (extension — с точкой, нижний регистр). Меняет ревизию. + public void SetBumperBackground(string extension) + { + BumperBackgroundExtension = extension; + BumperRevision++; + } + + public void ClearBumperBackground() + { + if (BumperBackgroundExtension is null) + return; + BumperBackgroundExtension = null; + BumperRevision++; + } + + /// Отметить загруженную музыку (extension — с точкой, нижний регистр). Меняет ревизию. + public void SetBumperMusic(string extension) + { + 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) + return false; + _jingles.Remove(jingle); + return true; + } + + public bool HasJingle(Guid mediaAssetId) => _jingles.Any(j => j.MediaAssetId == mediaAssetId); + + /// Планировщик двигает курсор пула джинглов по мере вставки отбивок. + public void SetNextJingleIndex(int index) => NextJingleIndex = index; + public ChannelShow? FindShow(Guid channelShowId) => _shows.FirstOrDefault(s => s.Id == channelShowId); public ChannelShow AddShow(Guid showId, int weight, BlockMode blockMode, int blockValue) diff --git a/backend/src/TeleWave.Domain/Broadcast/ChannelJingle.cs b/backend/src/TeleWave.Domain/Broadcast/ChannelJingle.cs new file mode 100644 index 0000000..08e59ca --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/ChannelJingle.cs @@ -0,0 +1,22 @@ +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/ScheduleEntry.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs index 0e327dc..b91fb6d 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs @@ -56,4 +56,23 @@ public class ScheduleEntry StartsAtUtc = startsAtUtc, EndsAtUtc = endsAtUtc, }; + + /// Заставка-переход. — следующее шоу (для EPG/справки). + public static ScheduleEntry Bumper( + Guid channelId, + Guid mediaAssetId, + DateTimeOffset startsAtUtc, + DateTimeOffset endsAtUtc, + Guid? showId + ) => + new() + { + Id = Guid.NewGuid(), + ChannelId = channelId, + MediaAssetId = mediaAssetId, + Kind = ScheduleEntryKind.Bumper, + StartsAtUtc = startsAtUtc, + EndsAtUtc = endsAtUtc, + ShowId = showId, + }; } diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs index 49951fb..aa4e1e9 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs @@ -8,4 +8,7 @@ public enum ScheduleEntryKind /// Рекламная врезка. Ad, + + /// ТВ-заставка на переходе между шоу («Сейчас: X · Далее: Y»). + Bumper, } diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs index 5285863..c60cb01 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs @@ -19,14 +19,18 @@ 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 anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0); if (!anyPlayable) - return new PlannerResult(entries, nextEpisode, nextAd); + return new PlannerResult(entries, nextEpisode, nextAd, nextJingle); var cursor = input.StartTime; var iterations = 0; + Guid? prevShowId = null; + DateTimeOffset? lastBumperAt = null; + var bumperCount = 0; while (cursor < input.HorizonEnd && iterations++ < IterationBackstop) { @@ -35,6 +39,28 @@ public static class SchedulePlanner break; var pick = WeightedPick(candidates, random); + + // ТВ-заставка на переходе. Динамическую (Сейчас/Далее) резервируем слотом фикс. длины — + // ассет отрендерит оркестратор; статичный джингл берём готовым из пула (реальная длина). + if ( + prevShowId is { } prev + && input.Bumpers is { Enabled: true } bumper + && (!bumper.OnlyBetweenDifferentShows || prev != pick.ShowId) + && ( + bumper.MinInterval <= TimeSpan.Zero + || lastBumperAt is not { } last + || cursor - last >= bumper.MinInterval + ) + ) + { + var bumperStart = cursor; + if (TryPlaceBumper(entries, bumper, bumperCount, prev, pick.ShowId, input, ref nextJingle, ref cursor)) + { + lastBumperAt = bumperStart; + bumperCount++; + } + } + var blockStart = cursor; var block = CollectBlock(pick, nextEpisode, input, cursor); @@ -64,9 +90,80 @@ public static class SchedulePlanner // Защита от зацикливания, если длительности нулевые/отсутствуют — эфир не сдвинулся. if (cursor <= blockStart) break; + + prevShowId = pick.ShowId; } - return new PlannerResult(entries, nextEpisode, nextAd); + return new PlannerResult(entries, nextEpisode, nextAd, nextJingle); + } + + /// + /// Ставит одну заставку на переходе по режиму канала. Динамическая — плейсхолдер фикс. длины + /// (ассет подставит оркестратор). Статичная — готовый джингл из пула (реальная длина, курсор + /// двигается). В режиме Both типы чередуются; при пустом пуле Both уходит в динамику. + /// Возвращает true, если заставка добавлена (курсор сдвинут). + /// + private static bool TryPlaceBumper( + List entries, + PlannerBumperConfig bumper, + int bumperCount, + Guid fromShowId, + Guid toShowId, + PlannerInput input, + ref int nextJingle, + 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) + return false; + + var dynEnd = cursor + bumper.Duration; + entries.Add( + new PlannedEntry( + Guid.Empty, + ScheduleEntryKind.Bumper, + cursor, + dynEnd, + toShowId, + null, + fromShowId, + toShowId + ) + ); + cursor = dynEnd; + return true; } private static List<(PlannerShow Show, int Weight)> ResolvePolicy( diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs index e849157..70eeeba 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs @@ -21,6 +21,20 @@ 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 +); + /// Полный вход планировщика для одного прогона по каналу. public sealed record PlannerInput( Guid ChannelId, @@ -32,22 +46,31 @@ public sealed record PlannerInput( IReadOnlyDictionary Durations, IReadOnlyList Overrides, DateTimeOffset StartTime, - DateTimeOffset HorizonEnd + DateTimeOffset HorizonEnd, + PlannerBumperConfig? Bumpers = null, + int NextJingleIndex = 0 ); -/// Одна запланированная запись (ещё не доменная сущность). +/// +/// Одна запланированная запись (ещё не доменная сущность). Для заставок ( == +/// ) пуст — его подставит +/// оркестратор после рендера по паре (). +/// public sealed record PlannedEntry( Guid MediaAssetId, ScheduleEntryKind Kind, DateTimeOffset StartsAtUtc, DateTimeOffset EndsAtUtc, Guid? ShowId, - int? EpisodeIndex + int? EpisodeIndex, + Guid? FromShowId = null, + Guid? ToShowId = null ); /// Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы). public sealed record PlannerResult( IReadOnlyList Entries, IReadOnlyDictionary NextEpisodeIndexByChannelShow, - int NextAdIndex + int NextAdIndex, + int NextJingleIndex ); diff --git a/backend/src/TeleWave.Domain/Media/MediaAsset.cs b/backend/src/TeleWave.Domain/Media/MediaAsset.cs index 6ecc740..46bb92c 100644 --- a/backend/src/TeleWave.Domain/Media/MediaAsset.cs +++ b/backend/src/TeleWave.Domain/Media/MediaAsset.cs @@ -55,6 +55,25 @@ public class MediaAsset }; } + /// + /// Ассет, сгенерированный системой (ТВ-заставка). Нарезку делает не общий пайплайн обработки, а + /// специализированный рендерер, поэтому сразу помечаем после создания. + /// + public static MediaAsset RegisterGenerated(string displayName) + { + var now = DateTimeOffset.UtcNow; + return new MediaAsset + { + Id = Guid.NewGuid(), + OriginalFileName = displayName, + OriginalExtension = string.Empty, + Source = MediaSource.Generated, + Status = MediaAssetStatus.Pending, + CreatedAt = now, + UpdatedAt = now, + }; + } + public void MarkProcessing() { Status = MediaAssetStatus.Processing; diff --git a/backend/src/TeleWave.Domain/Media/MediaSource.cs b/backend/src/TeleWave.Domain/Media/MediaSource.cs index 3bab17e..c83bda6 100644 --- a/backend/src/TeleWave.Domain/Media/MediaSource.cs +++ b/backend/src/TeleWave.Domain/Media/MediaSource.cs @@ -8,4 +8,8 @@ public enum MediaSource /// Положен вручную в inbox/ и подобран сканером. Inbox, + + /// Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком. + /// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя). + Generated, } diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 29e8e87..7154e8c 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Streaming; @@ -102,9 +103,11 @@ public static class DependencyInjection { services.Configure(configuration.GetSection(SchedulerOptions.SectionName)); services.Configure(configuration.GetSection(StreamingOptions.SectionName)); + services.Configure(configuration.GetSection(BumperOptions.SectionName)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddHostedService(); } @@ -117,6 +120,7 @@ public static class DependencyInjection 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 new file mode 100644 index 0000000..a55cf15 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs @@ -0,0 +1,78 @@ +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Файловое хранилище шаблонов заставок: сырые фон/музыка под bumpers/{channelId}/{kind}{ext}. +/// На канал — не более одного файла каждого вида (при загрузке старый удаляется). +/// +public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemplateStorage +{ + private const string Background = "background"; + private const string Music = "music"; + + public Task SaveBackgroundAsync( + Guid channelId, + string extension, + Stream content, + CancellationToken cancellationToken + ) => SaveAsync(channelId, Background, extension, content, cancellationToken); + + public Task SaveMusicAsync( + Guid channelId, + string extension, + Stream content, + CancellationToken cancellationToken + ) => SaveAsync(channelId, Music, extension, content, cancellationToken); + + public void DeleteBackground(Guid channelId) => DeleteKind(channelId, Background); + + public void DeleteMusic(Guid channelId) => DeleteKind(channelId, Music); + + public string? BackgroundPath(Guid channelId, string? extension) => + ResolvePath(channelId, Background, extension); + + public string? MusicPath(Guid channelId, string? extension) => + ResolvePath(channelId, Music, extension); + + private async Task SaveAsync( + Guid channelId, + string kind, + string extension, + Stream content, + CancellationToken cancellationToken + ) + { + var dir = paths.BumperChannelDir(channelId); + Directory.CreateDirectory(dir); + RemoveExisting(dir, kind); + + var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension)); + await using var fs = File.Create(path); + await content.CopyToAsync(fs, cancellationToken); + } + + private void DeleteKind(Guid channelId, string kind) + { + var dir = paths.BumperChannelDir(channelId); + if (Directory.Exists(dir)) + RemoveExisting(dir, kind); + } + + private string? ResolvePath(Guid channelId, string kind, string? extension) + { + if (string.IsNullOrWhiteSpace(extension)) + return null; + var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension)); + return File.Exists(path) ? path : null; + } + + private static void RemoveExisting(string dir, string kind) + { + foreach (var file in Directory.EnumerateFiles(dir, kind + ".*")) + File.Delete(file); + } + + private static string NormalizeExtension(string extension) => + extension.StartsWith('.') ? extension : "." + extension; +} diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs new file mode 100644 index 0000000..133fbc9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs @@ -0,0 +1,251 @@ +using System.Globalization; +using System.Text; +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Синтезирует ТВ-заставку перехода полностью на ffmpeg (без исходного файла) по +/// : анимированный градиентный фон + текст «Сейчас/Далее» + короткий +/// джингл, и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и +/// кратная сегменту, поэтому эфирная математика не отличает заставку от программы. +/// +public sealed class FfmpegBumperRenderer( + MediaPathResolver paths, + IOptions storageOptions, + IOptions mediaOptions +) : IBumperRenderer +{ + private readonly StorageOptions _storage = storageOptions.Value; + private readonly MediaOptions _media = mediaOptions.Value; + + public async Task RenderAsync( + Guid assetId, + BumperRenderSpec spec, + CancellationToken cancellationToken + ) + { + var seg = Math.Max(1, _storage.SegmentSeconds); + // Длительность — вверх до кратности сегменту (оркестратор обычно уже выровнял, страхуемся). + var target = (int)(Math.Ceiling((double)Math.Max(seg, spec.DurationSeconds) / seg) * seg); + + var assetDir = paths.AssetDir(assetId); + if (Directory.Exists(assetDir)) + Directory.Delete(assetDir, recursive: true); + Directory.CreateDirectory(assetDir); + + // Динамический текст (названия шоу) пишем в файлы и читаем через textfile= с expansion=none — + // так произвольные символы/кириллица не ломают синтаксис фильтра. + var nowFile = Path.Combine(assetDir, "now.txt"); + var nextFile = Path.Combine(assetDir, "next.txt"); + await File.WriteAllTextAsync(nowFile, spec.NowTitle, new UTF8Encoding(false), cancellationToken); + await File.WriteAllTextAsync(nextFile, spec.NextTitle, new UTF8Encoding(false), cancellationToken); + + try + { + var args = BuildArgs(assetDir, seg, target, nowFile, nextFile, spec); + var result = await ProcessRunner.RunAsync( + _media.FfmpegPath, + args, + lowPriority: true, + cancellationToken + ); + if (result.ExitCode != 0) + throw new InvalidOperationException( + $"ffmpeg (заставка) завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}" + ); + + var playlist = Path.Combine(assetDir, "index.m3u8"); + if (!File.Exists(playlist)) + throw new InvalidOperationException("ffmpeg не создал плейлист заставки index.m3u8."); + + var segmentCount = Directory.GetFiles(assetDir, "seg*.ts").Length; + if (segmentCount == 0) + throw new InvalidOperationException("ffmpeg не создал ни одного сегмента заставки."); + + return new BumperRenderResult( + TimeSpan.FromSeconds(target), + seg, + segmentCount, + spec.Width, + spec.Height, + paths.AssetRelativePath(assetId) + ); + } + finally + { + TryDelete(nowFile); + TryDelete(nextFile); + } + } + + private List BuildArgs( + string assetDir, + int seg, + int target, + string nowFile, + string nextFile, + BumperRenderSpec spec + ) + { + var w = spec.Width; + var h = spec.Height; + + var titleSize = Math.Max(24, h / 10); + var labelSize = Math.Max(14, h / 22); + var gap = (int)(labelSize * 1.4); + + var nowLabelY = (int)(h * 0.22); + var nowTitleY = nowLabelY + gap; + var nextLabelY = (int)(h * 0.60); + var nextTitleY = nextLabelY + gap; + + var font = EscapePath(spec.FontFile); + var outStart = Math.Max(0, target - 1); + + // Вход 0 — видеофон: загруженный файл (петля + масштаб/кроп) либо анимированный градиент. + var inputs = new List(); + string videoPrefix; + if (!string.IsNullOrEmpty(spec.BackgroundFile)) + { + if (IsImage(spec.BackgroundFile)) + inputs.AddRange(["-loop", "1"]); + else + inputs.AddRange(["-stream_loop", "-1"]); + inputs.AddRange(["-i", spec.BackgroundFile]); + videoPrefix = + $"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps=30,format=yuv420p"; + } + else + { + var gradient = + $"gradients=s={w}x{h}:c0={spec.BackgroundColor}:c1={spec.BackgroundColor2}" + + $":x0=0:y0=0:x1={w}:y1={h}:nb_colors=2:seed=42:speed=0.015:r=30:d={Fmt(target)}"; + inputs.AddRange(["-f", "lavfi", "-i", gradient]); + videoPrefix = "[0:v]format=yuv420p"; + } + + // Вход 1 — звук: загруженная музыка (петля + loudnorm) либо синтезированный джингл. + string audioChain; + if (!string.IsNullOrEmpty(spec.MusicFile)) + { + inputs.AddRange(["-stream_loop", "-1", "-i", spec.MusicFile]); + audioChain = + $"[1:a]loudnorm=I={_media.LoudnessTargetLufs.ToString(CultureInfo.InvariantCulture)}:TP=-1.5:LRA=11" + + $",afade=t=in:d=0.5,afade=t=out:st={Fmt(outStart)}:d=1.0[a]"; + } + else + { + var jingle = + $"aevalsrc=exprs='0.14*sin(2*PI*523.25*t)+0.11*sin(2*PI*659.25*t)+0.09*sin(2*PI*783.99*t)'" + + $":s=48000:d={Fmt(target)}"; + inputs.AddRange(["-f", "lavfi", "-i", jingle]); + audioChain = + $"[1:a]tremolo=f=4:d=0.4,afade=t=in:d=0.5,afade=t=out:st={Fmt(outStart)}:d=1.0,volume=0.7[a]"; + } + + var vchain = new StringBuilder(videoPrefix); + vchain.Append(',').Append(DrawLabel(font, spec.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2)); + vchain.Append(',').Append(DrawTitle(font, nowFile, spec.TextColor, titleSize, nowTitleY, 0.3)); + vchain.Append(',').Append(DrawLabel(font, spec.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0)); + vchain.Append(',').Append(DrawTitle(font, nextFile, spec.TextColor, titleSize, nextTitleY, 1.1)); + vchain.Append("[v]"); + + var filterComplex = $"{vchain};{audioChain}"; + + var args = new List { "-hide_banner", "-nostdin", "-y" }; + args.AddRange(inputs); + args.AddRange( + [ + "-filter_complex", filterComplex, + "-map", "[v]", + "-map", "[a]", + "-threads", _media.TranscodeThreads.ToString(CultureInfo.InvariantCulture), + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "21", + "-pix_fmt", "yuv420p", + "-force_key_frames", $"expr:gte(t,n_forced*{seg.ToString(CultureInfo.InvariantCulture)})", + "-sc_threshold", "0", + "-c:a", "aac", + "-b:a", "128k", + "-ac", "2", + "-ar", "48000", + "-t", Fmt(target), + "-f", "hls", + "-hls_time", seg.ToString(CultureInfo.InvariantCulture), + "-hls_playlist_type", "vod", + "-hls_list_size", "0", + "-hls_segment_filename", Path.Combine(assetDir, "seg%05d.ts"), + Path.Combine(assetDir, "index.m3u8"), + ]); + return args; + } + + private static readonly string[] ImageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".bmp"]; + + private static bool IsImage(string path) => + ImageExtensions.Contains(Path.GetExtension(path).ToLowerInvariant()); + + private static string DrawTitle( + string font, + string textFile, + string color, + int size, + int y, + double fadeStart + ) => + $"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none" + + $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}" + + ":shadowcolor=black@0.6:shadowx=2:shadowy=2" + + $":alpha='{FadeExpr(fadeStart)}'"; + + private static string DrawLabel( + string font, + string text, + string color, + int size, + int y, + double fadeStart + ) => + $"drawtext=fontfile={font}:text={EscapeText(text)}:expansion=none" + + $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}" + + ":shadowcolor=black@0.6:shadowx=1:shadowy=1" + + $":alpha='{FadeExpr(fadeStart)}'"; + + private static string FadeExpr(double start) => + $"if(lt(t,{Fmt(start)}),0,min(1,(t-{Fmt(start)})/0.5))"; + + /// Экранирование пути для значения опции фильтра (Windows-разделители → прямые слэши, + /// двоеточие экранируется). На Linux (контейнере) — фактически no-op. + private static string EscapePath(string path) => + path.Replace('\\', '/').Replace(":", "\\:"); + + /// Экранирование литерального текста подписи внутри значения опции drawtext. + private static string EscapeText(string text) => + text.Replace("\\", "\\\\").Replace(":", "\\:").Replace("'", "\\'"); + + private static string Fmt(double value) => + value.ToString("0.###", CultureInfo.InvariantCulture); + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (IOException) + { + // Файл-подсказка для drawtext; не критично, если не удалился. + } + } + + private static string Tail(string text) + { + text = text.Trim(); + const int max = 500; + return text.Length <= max ? text : text[^max..]; + } +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs index d7b6254..a437f65 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs @@ -17,6 +17,7 @@ public sealed class MediaPathResolver UploadsDir = Path.Combine(_root, "uploads"); OriginalsDir = Path.Combine(_root, "originals"); AssetsDir = Path.Combine(_root, "assets"); + BumpersDir = Path.Combine(_root, "bumpers"); } public string InboxDir { get; } @@ -24,14 +25,25 @@ public sealed class MediaPathResolver public string OriginalsDir { get; } public string AssetsDir { get; } + /// Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS. + public string BumpersDir { get; } + public void EnsureDirectories() { Directory.CreateDirectory(InboxDir); Directory.CreateDirectory(UploadsDir); Directory.CreateDirectory(OriginalsDir); Directory.CreateDirectory(AssetsDir); + Directory.CreateDirectory(BumpersDir); } + public string BumperChannelDir(Guid channelId) => + EnsureWithinRoot(Path.Combine(BumpersDir, channelId.ToString("N"))); + + /// Путь к файлу шаблона заставки (kind — «background»/«music», extension — с точкой). + public string BumperFilePath(Guid channelId, string kind, string extension) => + EnsureWithinRoot(Path.Combine(BumpersDir, channelId.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/20260724204325_AddBumpers.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724204325_AddBumpers.Designer.cs new file mode 100644 index 0000000..1d601b4 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724204325_AddBumpers.Designer.cs @@ -0,0 +1,713 @@ +// +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("20260724204325_AddBumpers")] + partial class AddBumpers + { + /// + 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.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .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("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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + 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.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.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("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/20260724204325_AddBumpers.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724204325_AddBumpers.cs new file mode 100644 index 0000000..6359e98 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724204325_AddBumpers.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class AddBumpers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BumpersEnabled", + table: "Channels", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "BumperAssets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FromShowId = table.Column(type: "uuid", nullable: false), + ToShowId = table.Column(type: "uuid", nullable: false), + Signature = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + MediaAssetId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BumperAssets", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_BumperAssets_FromShowId_ToShowId_Signature", + table: "BumperAssets", + columns: new[] { "FromShowId", "ToShowId", "Signature" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BumperAssets"); + + migrationBuilder.DropColumn( + name: "BumpersEnabled", + table: "Channels"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260724210635_ChannelBumperSettings.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724210635_ChannelBumperSettings.Designer.cs new file mode 100644 index 0000000..ba83dd3 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724210635_ChannelBumperSettings.Designer.cs @@ -0,0 +1,749 @@ +// +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("20260724210635_ChannelBumperSettings")] + partial class ChannelBumperSettings + { + /// + 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.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + 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("BumperDurationSeconds") + .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("BumperTextColor") + .IsRequired() + .HasColumnType("text"); + + 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("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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + 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.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.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("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/20260724210635_ChannelBumperSettings.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724210635_ChannelBumperSettings.cs new file mode 100644 index 0000000..520ece6 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724210635_ChannelBumperSettings.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ChannelBumperSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BumperAccentColor", + table: "Channels", + type: "text", + nullable: false, + defaultValue: "0x38bdf8"); + + migrationBuilder.AddColumn( + name: "BumperBackgroundColor", + table: "Channels", + type: "text", + nullable: false, + defaultValue: "0x0b1020"); + + migrationBuilder.AddColumn( + name: "BumperBackgroundColor2", + table: "Channels", + type: "text", + nullable: false, + defaultValue: "0x1e293b"); + + migrationBuilder.AddColumn( + name: "BumperDurationSeconds", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 8); + + migrationBuilder.AddColumn( + name: "BumperFont", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "BumperMinIntervalMinutes", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "BumperNextLabel", + table: "Channels", + type: "text", + nullable: false, + defaultValue: "ДАЛЕЕ"); + + migrationBuilder.AddColumn( + name: "BumperNowLabel", + table: "Channels", + type: "text", + nullable: false, + defaultValue: "СЕЙЧАС"); + + migrationBuilder.AddColumn( + name: "BumperOnlyBetweenDifferentShows", + table: "Channels", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "BumperTextColor", + table: "Channels", + type: "text", + nullable: false, + defaultValue: "white"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BumperAccentColor", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperBackgroundColor", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperBackgroundColor2", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperDurationSeconds", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperFont", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperMinIntervalMinutes", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperNextLabel", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperNowLabel", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperOnlyBetweenDifferentShows", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperTextColor", + table: "Channels"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260724212853_BumperFilesAndJingles.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724212853_BumperFilesAndJingles.Designer.cs new file mode 100644 index 0000000..adaa3b1 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724212853_BumperFilesAndJingles.Designer.cs @@ -0,0 +1,796 @@ +// +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("20260724212853_BumperFilesAndJingles")] + partial class BumperFilesAndJingles + { + /// + 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.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + 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"); + + b.Property("BumperNowLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperOnlyBetweenDifferentShows") + .HasColumnType("boolean"); + + b.Property("BumperRevision") + .HasColumnType("integer"); + + b.Property("BumperTextColor") + .IsRequired() + .HasColumnType("text"); + + 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("NextJingleIndex") + .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.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") + .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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + 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.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.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Jingles") + .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("Jingles"); + + 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/20260724212853_BumperFilesAndJingles.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724212853_BumperFilesAndJingles.cs new file mode 100644 index 0000000..57425bf --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724212853_BumperFilesAndJingles.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class BumperFilesAndJingles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BumperBackgroundExtension", + table: "Channels", + type: "text", + nullable: true); + + 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: "BumperRevision", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "NextJingleIndex", + table: "Channels", + type: "integer", + nullable: false, + defaultValue: 0); + + 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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChannelJingle"); + + migrationBuilder.DropColumn( + name: "BumperBackgroundExtension", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperMode", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperMusicExtension", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperRevision", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "NextJingleIndex", + table: "Channels"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 3afcfbd..118b926 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -128,7 +128,6 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("CreatedAt") @@ -160,10 +159,38 @@ namespace TeleWave.Infrastructure.Migrations 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.Channel", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("AdInsertion") @@ -172,6 +199,57 @@ 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"); + + b.Property("BumperNowLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperOnlyBetweenDifferentShows") + .HasColumnType("boolean"); + + b.Property("BumperRevision") + .HasColumnType("integer"); + + b.Property("BumperTextColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -192,6 +270,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("NextAdIndex") .HasColumnType("integer"); + b.Property("NextJingleIndex") + .HasColumnType("integer"); + b.Property("Slug") .IsRequired() .HasMaxLength(128) @@ -208,7 +289,6 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("ChannelId") @@ -227,10 +307,30 @@ 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") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("BlockMode") @@ -264,7 +364,6 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("ProgrammingOverrideId") @@ -286,7 +385,6 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("ChannelId") @@ -311,7 +409,6 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("ChannelId") @@ -349,7 +446,6 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Library.Show", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("CreatedAt") @@ -375,7 +471,6 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("CreatedAt") @@ -402,7 +497,6 @@ namespace TeleWave.Infrastructure.Migrations modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("AudioCodec") @@ -628,6 +722,15 @@ namespace TeleWave.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Jingles") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => { b.HasOne("TeleWave.Domain.Broadcast.Channel", null) @@ -668,6 +771,8 @@ namespace TeleWave.Infrastructure.Migrations { b.Navigation("Ads"); + b.Navigation("Jingles"); + b.Navigation("Overrides"); b.Navigation("Shows"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index 0035820..46126b8 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions options) public DbSet Shows => Set(); public DbSet Channels => Set(); public DbSet ScheduleEntries => Set(); + public DbSet BumperAssets => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/BumperAssetConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/BumperAssetConfiguration.cs new file mode 100644 index 0000000..4b9e0d5 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/BumperAssetConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Infrastructure.Persistence.Configurations; + +public class BumperAssetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(x => x.Signature).IsRequired().HasMaxLength(128); + + // Кэш-ключ заставки: одна отрендеренная пара «из→в» при данной сигнатуре оформления. + builder.HasIndex(x => new + { + x.FromShowId, + x.ToShowId, + x.Signature, + }); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs index ae465ed..1651602 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs @@ -27,6 +27,13 @@ public class ChannelConfiguration : IEntityTypeConfiguration .OnDelete(DeleteBehavior.Cascade); builder.Navigation(x => x.Ads).UsePropertyAccessMode(PropertyAccessMode.Field); + builder + .HasMany(x => x.Jingles) + .WithOne() + .HasForeignKey(j => j.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.Jingles).UsePropertyAccessMode(PropertyAccessMode.Field); + builder .HasMany(x => x.Overrides) .WithOne() @@ -52,6 +59,14 @@ public class ChannelAdConfiguration : IEntityTypeConfiguration } } +public class ChannelJingleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => new { x.ChannelId, x.Position }); + } +} + public class ProgrammingOverrideConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs index d27c665..98c2d8c 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs @@ -203,6 +203,200 @@ public class SchedulePlannerTests Assert.Equal(b.ShowId, result.Entries[0].ShowId); } + [Fact] + public void Bumpers_InsertedOnTransitionBetweenDifferentShows_BackToBack() + { + 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)); + + var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with + { + Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero), + }; + + // Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2). + var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); + + // Перед первым блоком заставки нет; далее по одной на каждый переход. + Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind); + var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); + Assert.NotEmpty(bumpers); + + var first = result.Entries[1]; + Assert.Equal(ScheduleEntryKind.Bumper, first.Kind); + Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор + Assert.Equal(a.ShowId, first.FromShowId); + Assert.Equal(b.ShowId, first.ToShowId); + Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc); + // Встык: программа → заставка → программа. + Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc); + Assert.Equal(first.EndsAtUtc, result.Entries[2].StartsAtUtc); + } + + [Fact] + public void Bumpers_SkippedForSameShow_WhenOnlyBetweenDifferentShows() + { + var cs = Guid.NewGuid(); + Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; + + var input = BaseInput( + [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)], + Durations((eps[0], 20), (eps[1], 20)), + horizonEnd: Start.AddMinutes(50) + ) with + { + Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero), + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0)); + + Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); + } + + [Fact] + public void Bumpers_InsertedForSameShow_WhenNotRestrictedToDifferent() + { + var cs = Guid.NewGuid(); + Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()]; + + var input = BaseInput( + [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)], + Durations((eps[0], 20), (eps[1], 20)), + horizonEnd: Start.AddMinutes(50) + ) with + { + Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: false, MinInterval: TimeSpan.Zero), + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0)); + + Assert.Contains(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); + } + + [Fact] + public void Bumpers_MinInterval_SuppressesTooFrequentBumpers() + { + 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)); + + // Два перехода в горизонте (~на 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) + ), + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); + + Assert.Equal(1, result.Entries.Count(e => e.Kind == ScheduleEntryKind.Bumper)); + } + + [Fact] + public void Bumpers_StaticMode_UsesJinglesFromPoolInRotation() + { + 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 input = BaseInput([a, b], durations, Start.AddMinutes(50)) with + { + Bumpers = new PlannerBumperConfig( + true, + TimeSpan.FromSeconds(8), + OnlyBetweenDifferentShows: true, + MinInterval: TimeSpan.Zero, + Mode: BumperMode.Static, + JinglePool: [j0, j1] + ), + }; + + 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); + } + + [Fact] + public void Bumpers_StaticMode_EmptyPool_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); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + + var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with + { + Bumpers = new PlannerBumperConfig( + true, + TimeSpan.FromSeconds(8), + OnlyBetweenDifferentShows: true, + MinInterval: TimeSpan.Zero, + Mode: BumperMode.Static, + JinglePool: [] + ), + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); + + 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() + { + 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)); + + var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with + { + Bumpers = new PlannerBumperConfig(false, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero), + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); + + Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); + } + [Fact] public void NoPlayableShows_ReturnsEmpty() { diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index d0d5f06..e37f293 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -7,6 +7,9 @@ import { HttpError } from '@/shared/api/client' import type { AdInsertion, BlockMode, + BumperFont, + BumperMode, + BumperSettings, ChannelShowDto, OverrideMode, ScheduleEntryDto, @@ -22,16 +25,22 @@ import { listMedia } from '@/features/admin/media/api' import { listShows } from '@/features/admin/shows/api' import { addChannelAd, + addChannelJingle, addChannelShow, + clearBumperBackground, + clearBumperMusic, createOverride, deleteOverride, getChannel, getSchedule, regenerateSchedule, removeChannelAd, + removeChannelJingle, removeChannelShow, updateChannelSettings, updateChannelShow, + uploadBumperBackground, + uploadBumperMusic, } from './api' function formatTime(iso: string) { @@ -184,6 +193,39 @@ export function ChannelDetail({ channelId }: { channelId: string }) { + {/* Джинглы-отбивки (статичные заставки) */} + + + {t('admin.channels.jingles')} + + +

{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'ы / марафоны */} @@ -245,13 +287,20 @@ function SettingsCard({ const [isEnabled, setIsEnabled] = useState(channel.isEnabled) const [adInsertion, setAdInsertion] = useState(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]) @@ -262,6 +311,8 @@ function SettingsCard({ isEnabled, adInsertion, adsPerBreak, + bumpersEnabled, + bumper, fillerAssetId: fillerAssetId || null, }), onSuccess: () => { @@ -327,6 +378,31 @@ function SettingsCard({ /> {t('admin.channels.enabledLabel')} + + {bumpersEnabled && ( +
+ +
+ )}
+ {has && ( + + )} +
+ + ) +} + function AddShowForm({ channelId, options, @@ -521,6 +827,50 @@ function AddAdForm({ ) } +function AddJingleForm({ + channelId, + options, + onAdded, + onError, +}: { + channelId: string + options: { id: string; name: string }[] + onAdded: () => void + onError: (e: unknown) => void +}) { + const { t } = useTranslation() + const [assetId, setAssetId] = useState('') + + const add = useMutation({ + mutationFn: () => addChannelJingle(channelId, assetId), + onSuccess: () => { + setAssetId('') + onAdded() + }, + onError, + }) + + return ( +
+ + +
+ ) +} + function OverrideForm({ channelId, options, @@ -611,6 +961,8 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) { {formatTime(e.startsAtUtc)} {e.kind === 'Ad' ? ( {t('air.ad')} + ) : e.kind === 'Bumper' ? ( + {t('air.bumper')} ) : ( {e.showName ?? '—'} diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index 89584ea..a08b761 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -1,7 +1,8 @@ -import { apiRequest } from '@/shared/api/client' +import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client' import type { AdInsertion, BlockMode, + BumperSettings, ChannelDto, ChannelSummaryDto, CreatedIdResponse, @@ -26,6 +27,8 @@ export type ChannelSettingsBody = { isEnabled: boolean adInsertion: AdInsertion adsPerBreak: number + bumpersEnabled: boolean + bumper: BumperSettings fillerAssetId: string | null } @@ -67,6 +70,60 @@ export function removeChannelAd(id: string, channelAdId: string) { return apiRequest(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' }) } +export function addChannelJingle(id: string, mediaAssetId: string) { + return apiRequest(`/admin/channels/${id}/jingles`, { + method: 'POST', + body: { mediaAssetId }, + }) +} + +export function removeChannelJingle(id: string, channelJingleId: string) { + return apiRequest(`/admin/channels/${id}/jingles/${channelJingleId}`, { method: 'DELETE' }) +} + +/** Загрузка сырого файла заставки (фон/музыка): тело — файл, имя — в query (как в uploadMedia). */ +function uploadBumperFile(id: string, kind: 'background' | 'music', file: File): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const query = new URLSearchParams({ fileName: file.name }) + xhr.open('PUT', `/api/admin/channels/${id}/bumper/${kind}?${query.toString()}`) + const token = getAccessToken() + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + let detail = `HTTP ${xhr.status}` + try { + const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string } + detail = problem.detail ?? problem.title ?? detail + } catch { + /* пусто */ + } + reject(new HttpError({ detail }, xhr.status)) + } + } + xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0)) + xhr.send(file) + }) +} + +export function uploadBumperBackground(id: string, file: File) { + return uploadBumperFile(id, 'background', file) +} + +export function uploadBumperMusic(id: string, file: File) { + return uploadBumperFile(id, 'music', file) +} + +export function clearBumperBackground(id: string) { + return apiRequest(`/admin/channels/${id}/bumper/background`, { method: 'DELETE' }) +} + +export function clearBumperMusic(id: string) { + return apiRequest(`/admin/channels/${id}/bumper/music`, { method: 'DELETE' }) +} + export type OverrideBody = { mode: OverrideMode startsAtUtc: string diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 3523eda..61efa94 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -73,6 +73,17 @@ export function ShowDetail({ showId }: { showId: string }) { const selected = candidates.filter((c) => !deselected.has(c.asset.id)) + // Сколько разных сезонов загружено — по распознанным SxxExx в именах серий. + const seasonCount = useMemo(() => { + if (!show) return 0 + const seasons = new Set() + for (const e of show.episodes) { + const se = parseEpisode(e.assetName ?? '') + if (se) seasons.add(se.season) + } + return seasons.size + }, [show]) + if (isLoading || !show) return

{t('common.loading')}

const isSingle = show.kind === 'Single' @@ -115,9 +126,17 @@ export function ShowDetail({ showId }: { showId: string }) { -
+

{show.name}

{t(`admin.shows.kinds.${show.kind}`)} + {show.kind === 'Series' && ( + + {t('admin.shows.seasons')}: {seasonCount} + + )} + + {t('admin.shows.episodes')}: {show.episodes.length} +
{canAdd && ( diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 37014ee..85f96da 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -91,7 +91,25 @@ export type ShowDto = { export type BlockMode = 'Count' | 'Duration' export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes' export type OverrideMode = 'Exclusive' | 'Boost' -export type ScheduleEntryKind = 'Program' | 'Ad' +export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' +export type BumperFont = 'Sans' | 'Serif' +export type BumperMode = 'Dynamic' | 'Static' | 'Both' + +export type BumperSettings = { + mode: BumperMode + durationSeconds: number + backgroundColor: string + backgroundColor2: string + accentColor: string + textColor: string + font: BumperFont + nowLabel: string + nextLabel: string + minIntervalMinutes: number + onlyBetweenDifferentShows: boolean + hasBackground: boolean + hasMusic: boolean +} export type ChannelSummaryDto = { id: string @@ -118,6 +136,13 @@ export type ChannelAdDto = { position: number } +export type ChannelJingleDto = { + id: string + mediaAssetId: string + assetName: string | null + position: number +} + export type OverrideShowDto = { showId: string; showName: string; weight: number } export type ProgrammingOverrideDto = { @@ -135,9 +160,12 @@ export type ChannelDto = { isEnabled: boolean adInsertion: AdInsertion adsPerBreak: number + bumpersEnabled: boolean + bumper: BumperSettings fillerAssetId: string | null shows: ChannelShowDto[] ads: ChannelAdDto[] + jingles: ChannelJingleDto[] overrides: ProgrammingOverrideDto[] } diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index ab57f33..a1b6381 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -61,6 +61,7 @@ const resources = { now: 'Сейчас', next: 'Далее', ad: 'Реклама', + bumper: 'Заставка', episode: 'Серия', live: 'В эфире', noChannels: 'Пока нет доступных каналов. Загляните позже.', @@ -156,6 +157,37 @@ const resources = { betweenBlocks: 'Между блоками', betweenEpisodes: 'Между сериями', adsPerBreak: 'Роликов подряд', + bumpersLabel: 'ТВ-заставки на переходах', + bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу', + bumperStyle: 'Оформление заставки', + bumperDuration: 'Длительность, с', + bumperMinInterval: 'Мин. интервал, мин', + bumperFont: 'Шрифт', + bumperFontSans: 'Гротеск', + bumperFontSerif: 'Антиква', + bumperNowLabel: 'Подпись «Сейчас»', + bumperNextLabel: 'Подпись «Далее»', + bumperBg: 'Фон (цвет 1)', + bumperBg2: 'Фон (цвет 2)', + bumperAccent: 'Акцент', + bumperText: 'Текст', + bumperOnlyDifferent: 'Только на смене шоу (не внутри марафона)', + bumperMode: 'Режим заставок', + bumperModeDynamic: 'Динамические «Сейчас/Далее»', + bumperModeStatic: 'Только джинглы', + bumperModeBoth: 'Чередовать', + bumperBackground: 'Фон', + bumperBackgroundHint: 'Картинка или видео-петля; иначе — анимированный градиент', + bumperMusic: 'Музыка', + bumperMusicHint: 'Аудиофайл-подложка; иначе — синтезированный джингл', + bumperFileLoaded: 'загружено', + bumperFileDefault: 'по умолчанию', + bumperUpload: 'Загрузить', + bumperReset: 'Сбросить', + jingles: 'Джинглы-отбивки', + jinglesHint: 'Готовые ролики для статичных заставок; крутятся по кругу на переходах (режимы «Только джинглы» и «Чередовать»).', + pickJingle: 'Выберите ролик', + noJingles: 'Пул джинглов пуст', filler: 'Заглушка', noFiller: 'Без заглушки', shows: 'Шоу канала', @@ -257,6 +289,7 @@ const resources = { now: 'Now', next: 'Up next', ad: 'Ad', + bumper: 'Bumper', episode: 'Episode', live: 'Live', noChannels: 'No channels available yet. Check back later.', @@ -352,6 +385,37 @@ const resources = { betweenBlocks: 'Between blocks', betweenEpisodes: 'Between episodes', adsPerBreak: 'Ads per break', + bumpersLabel: 'Transition bumpers', + bumpersHint: 'Short “Now / Next” bumper between different shows', + bumperStyle: 'Bumper style', + bumperDuration: 'Duration, s', + bumperMinInterval: 'Min interval, min', + bumperFont: 'Font', + bumperFontSans: 'Sans', + bumperFontSerif: 'Serif', + bumperNowLabel: '“Now” label', + bumperNextLabel: '“Next” label', + bumperBg: 'Background (color 1)', + bumperBg2: 'Background (color 2)', + bumperAccent: 'Accent', + bumperText: 'Text', + bumperOnlyDifferent: 'Only on show change (not within a marathon)', + bumperMode: 'Bumper mode', + bumperModeDynamic: 'Dynamic “Now / Next”', + bumperModeStatic: 'Jingles only', + bumperModeBoth: 'Alternate', + bumperBackground: 'Background', + bumperBackgroundHint: 'Image or video loop; otherwise an animated gradient', + bumperMusic: 'Music', + bumperMusicHint: 'Audio bed file; otherwise a synthesized jingle', + bumperFileLoaded: 'loaded', + bumperFileDefault: 'default', + bumperUpload: 'Upload', + bumperReset: 'Reset', + jingles: 'Jingles', + jinglesHint: 'Pre-made clips for static bumpers; rotated on transitions (in “Jingles only” and “Alternate” modes).', + pickJingle: 'Pick a clip', + noJingles: 'Jingle pool is empty', filler: 'Filler', noFiller: 'No filler', shows: 'Channel shows',