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',