Refactor Channel endpoints and data models: replace jingle functionality with bumper templates, update related commands and handlers, and enhance API routes for managing bumper templates. Remove obsolete jingle-related code and adjust channel data structures to support new bumper template features.

This commit is contained in:
Leonid Pershin
2026-07-25 11:02:16 +03:00
parent 27571a4ab6
commit 84c2867062
60 changed files with 2805 additions and 1045 deletions
@@ -1,11 +1,11 @@
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.AddChannelAd;
using TeleWave.Application.Broadcast.AddChannelJingle;
using TeleWave.Application.Broadcast.AddChannelShow;
using TeleWave.Application.Broadcast.BumperBackground;
using TeleWave.Application.Broadcast.BumperMusic;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.CreateOverride;
using TeleWave.Application.Broadcast.DeleteOverride;
@@ -14,18 +14,23 @@ using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.RegenerateSchedule;
using TeleWave.Application.Broadcast.RemoveChannelAd;
using TeleWave.Application.Broadcast.RemoveChannelJingle;
using TeleWave.Application.Broadcast.RemoveChannelShow;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelShow;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
public static class ChannelEndpoints
{
private static readonly Regex BumperSegmentFileName = new(
@"^seg\d{1,6}\.ts$",
RegexOptions.Compiled
);
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
@@ -55,24 +60,38 @@ public static class ChannelEndpoints
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/jingles", AddJingle)
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/jingles/{channelJingleId:guid}", RemoveJingle)
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/background", UploadTemplateBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/background", ClearTemplateBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/background", UploadBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/background", ClearBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/music", UploadMusic)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/music", ClearMusic)
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/index.m3u8",
PreviewPlaylist
);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{file}",
PreviewSegment
);
admin
.MapPost("/{id:guid}/overrides", CreateOverride)
@@ -216,15 +235,15 @@ public static class ChannelEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> AddJingle(
private static async Task<IResult> AddBumperTemplate(
Guid id,
AddChannelJingleBody body,
AddBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddChannelJingleCommand(id, body.MediaAssetId),
new AddBumperTemplateCommand(id, body.Name),
cancellationToken
);
return result.IsSuccess
@@ -232,22 +251,91 @@ public static class ChannelEndpoints
: result.ToHttpResult();
}
private static async Task<IResult> RemoveJingle(
private static async Task<IResult> UpdateBumperTemplate(
Guid id,
Guid channelJingleId,
Guid templateId,
UpdateBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveChannelJingleCommand(id, channelJingleId),
new UpdateBumperTemplateCommand(
id,
templateId,
body.Name,
body.BackgroundColor,
body.BackgroundColor2,
body.AccentColor,
body.TextColor
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadBackground(
private static async Task<IResult> RemoveBumperTemplate(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveBumperTemplateCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadTemplateAudio(
Guid id,
Guid templateId,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
IAudioProbe probe,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
var path = storage.AudioPath(templateId, ext);
var duration = path is null
? null
: await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send(
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0),
cancellationToken
);
if (!result.IsSuccess)
storage.DeleteAudio(templateId);
return result.ToHttpResult();
}
private static async Task<IResult> ClearTemplateAudio(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateAudioCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadTemplateBackground(
Guid id,
Guid templateId,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
@@ -258,52 +346,96 @@ public static class ChannelEndpoints
if (ResolveBumperExtension(fileName, request, BumperFiles.BackgroundExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveBackgroundAsync(id, ext, request.Body, cancellationToken);
await storage.SaveBackgroundAsync(templateId, ext, request.Body, cancellationToken);
var result = await sender.Send(new SetBumperBackgroundCommand(id, ext), cancellationToken);
var result = await sender.Send(
new SetBumperTemplateBackgroundCommand(id, templateId, ext),
cancellationToken
);
if (!result.IsSuccess)
storage.DeleteBackground(id);
storage.DeleteBackground(templateId);
return result.ToHttpResult();
}
private static async Task<IResult> ClearBackground(
private static async Task<IResult> ClearTemplateBackground(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearBumperBackgroundCommand(id), cancellationToken);
var result = await sender.Send(
new ClearBumperTemplateBackgroundCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadMusic(
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
private static async Task<IResult> RenderPreview(
Guid id,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveBumperExtension(fileName, request, BumperFiles.MusicExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveMusicAsync(id, ext, request.Body, cancellationToken);
var result = await sender.Send(new SetBumperMusicCommand(id, ext), cancellationToken);
if (!result.IsSuccess)
storage.DeleteMusic(id);
return result.ToHttpResult();
var result = await sender.Send(
new RenderBumperPreviewQuery(id, templateId),
cancellationToken
);
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
}
private static async Task<IResult> ClearMusic(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
/// <summary>Плейлист превью: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, Guid templateId, MediaPathResolver paths)
{
var result = await sender.Send(new ClearBumperMusicCommand(id), cancellationToken);
return result.ToHttpResult();
var previewId = BumperPreview.AssetId(templateId);
string indexPath;
try
{
indexPath = paths.SegmentPath(previewId, "index.m3u8");
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(indexPath))
return Results.NotFound();
var baseUrl = $"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Комментарии/директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
private static IResult PreviewSegment(Guid id, Guid templateId, string file, MediaPathResolver paths)
{
if (!BumperSegmentFileName.IsMatch(file))
return Results.NotFound();
var previewId = BumperPreview.AssetId(templateId);
string path;
try
{
path = paths.SegmentPath(previewId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
@@ -405,13 +537,22 @@ public sealed record UpdateChannelShowBody(
public sealed record AddChannelAdBody(Guid MediaAssetId);
public sealed record AddChannelJingleBody(Guid MediaAssetId);
public sealed record AddBumperTemplateBody(string Name);
/// <summary>Ограничения на загружаемые файлы заставки (фон/музыка).</summary>
public sealed record UpdateBumperTemplateBody(
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor
);
/// <summary>Ограничения на загружаемые файлы блока заставки (звук/фон-картинка).</summary>
internal static class BumperFiles
{
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
// Фон блока — только картинка (видео-фоны в новой модели не поддерживаются).
public static readonly IReadOnlySet<string> BackgroundExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
@@ -422,13 +563,9 @@ internal static class BumperFiles
".webp",
".bmp",
".gif",
".mp4",
".mov",
".mkv",
".webm",
};
public static readonly IReadOnlySet<string> MusicExtensions = new HashSet<string>(
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelJingle;
public sealed record AddChannelJingleCommand(Guid ChannelId, Guid MediaAssetId)
: ICommand<Result<Guid>>;
@@ -1,35 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelJingle;
public sealed class AddChannelJingleCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddChannelJingleCommand, Result<Guid>>
{
public async Task<Result<Guid>> 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<Guid>(ChannelErrors.NotFound);
var assetExists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == command.MediaAssetId,
cancellationToken
);
if (!assetExists)
return Result.Failure<Guid>(ChannelErrors.AssetNotFound);
if (channel.HasJingle(command.MediaAssetId))
return Result.Failure<Guid>(ChannelErrors.JingleAlreadyAdded);
var jingle = channel.AddJingle(command.MediaAssetId);
return Result.Success(jingle.Id);
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.BumperBackground;
public sealed record ClearBumperBackgroundCommand(Guid ChannelId) : ICommand<Result>;
@@ -1,29 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.BumperBackground;
public sealed class ClearBumperBackgroundCommandHandler(
IAppDbContext dbContext,
IBumperTemplateStorage storage
) : ICommandHandler<ClearBumperBackgroundCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.BumperBackground;
/// <summary>Отметить, что для канала загружен фон заставки (файл уже сохранён хранилищем).</summary>
public sealed record SetBumperBackgroundCommand(Guid ChannelId, string Extension) : ICommand<Result>;
@@ -1,26 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.BumperBackground;
public sealed class SetBumperBackgroundCommandHandler(IAppDbContext dbContext)
: ICommandHandler<SetBumperBackgroundCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.BumperMusic;
public sealed record ClearBumperMusicCommand(Guid ChannelId) : ICommand<Result>;
@@ -1,29 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.BumperMusic;
public sealed class ClearBumperMusicCommandHandler(
IAppDbContext dbContext,
IBumperTemplateStorage storage
) : ICommandHandler<ClearBumperMusicCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.BumperMusic;
/// <summary>Отметить, что для канала загружена музыка заставки (файл уже сохранён хранилищем).</summary>
public sealed record SetBumperMusicCommand(Guid ChannelId, string Extension) : ICommand<Result>;
@@ -1,26 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.BumperMusic;
public sealed class SetBumperMusicCommandHandler(IAppDbContext dbContext)
: ICommandHandler<SetBumperMusicCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Добавить новый блок заставки на канал (звук/фон загружаются отдельно).</summary>
public sealed record AddBumperTemplateCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
@@ -0,0 +1,28 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class AddBumperTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddBumperTemplateCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddBumperTemplateCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var name = string.IsNullOrWhiteSpace(command.Name)
? $"Заставка {channel.BumperTemplates.Count + 1}"
: command.Name.Trim();
var template = channel.AddBumperTemplate(name);
return Result.Success(template.Id);
}
}
@@ -0,0 +1,12 @@
using System.Security.Cryptography;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер,
/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые.
/// </summary>
public static class BumperPreview
{
public static Guid AssetId(Guid templateId) => new(MD5.HashData(templateId.ToByteArray()));
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить загруженный звук блока (вернуться к синтезированному джинглу).</summary>
public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId)
: ICommand<Result>;
@@ -0,0 +1,32 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class ClearBumperTemplateAudioCommandHandler(
IAppDbContext dbContext,
IBumperTemplateStorage storage
) : ICommandHandler<ClearBumperTemplateAudioCommand, Result>
{
public async Task<Result> Handle(
ClearBumperTemplateAudioCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
template.ClearAudio();
storage.DeleteAudio(command.TemplateId);
return Result.Success();
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить загруженную фон-картинку блока (вернуться к градиенту/постеру).</summary>
public sealed record ClearBumperTemplateBackgroundCommand(Guid ChannelId, Guid TemplateId)
: ICommand<Result>;
@@ -0,0 +1,32 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class ClearBumperTemplateBackgroundCommandHandler(
IAppDbContext dbContext,
IBumperTemplateStorage storage
) : ICommandHandler<ClearBumperTemplateBackgroundCommand, Result>
{
public async Task<Result> Handle(
ClearBumperTemplateBackgroundCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
template.ClearBackgroundImage();
storage.DeleteBackground(command.TemplateId);
return Result.Success();
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить блок заставки (кроме дефолтного) и его файлы.</summary>
public sealed record RemoveBumperTemplateCommand(Guid ChannelId, Guid TemplateId) : ICommand<Result>;
@@ -0,0 +1,34 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RemoveBumperTemplateCommandHandler(
IAppDbContext dbContext,
IBumperTemplateStorage storage
) : ICommandHandler<RemoveBumperTemplateCommand, Result>
{
public async Task<Result> Handle(
RemoveBumperTemplateCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
if (template.IsDefault)
return Result.Failure(ChannelErrors.CannotRemoveDefaultBumperTemplate);
channel.RemoveBumperTemplate(command.TemplateId);
storage.DeleteTemplate(command.TemplateId);
return Result.Success();
}
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Синхронно рендерит пример заставки блока (с примерными названиями шоу) и возвращает id
/// ассета-превью. БД не меняет — это read-side генерация артефакта для предпросмотра.
/// </summary>
public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId)
: IQuery<Result<Guid>>;
@@ -0,0 +1,90 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RenderBumperPreviewQueryHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
IBumperTemplateStorage storage,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : IQueryHandler<RenderBumperPreviewQuery, Result<Guid>>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result<Guid>> Handle(
RenderBumperPreviewQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.Include(c => c.Shows)
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
if (template is null)
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
var seconds =
template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds;
var aligned = (int)(
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
);
var spec = new BumperRenderSpec(
aligned,
_bumper.Width,
_bumper.Height,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans,
channel.BumperNowLabel,
fromName,
channel.BumperNextLabel,
toName,
storage.BackgroundPath(template.Id, template.BackgroundImageExtension),
storage.AudioPath(template.Id, template.AudioExtension),
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
null
);
var previewId = BumperPreview.AssetId(template.Id);
await renderer.RenderAsync(previewId, spec, cancellationToken);
return Result.Success(previewId);
}
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary>
private async Task<(string From, string To)> SampleNamesAsync(
Channel channel,
CancellationToken cancellationToken
)
{
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().Take(2).ToList();
var names = showIds.Count == 0
? []
: await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => s.Name)
.Take(2)
.ToListAsync(cancellationToken);
return (names.ElementAtOrDefault(0) ?? "Первое шоу", names.ElementAtOrDefault(1) ?? "Второе шоу");
}
}
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Отметить загруженный звук блока: расширение (с точкой) и длину в секундах (замер ffprobe).</summary>
public sealed record SetBumperTemplateAudioCommand(
Guid ChannelId,
Guid TemplateId,
string Extension,
double DurationSeconds
) : ICommand<Result>;
@@ -0,0 +1,29 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class SetBumperTemplateAudioCommandHandler(IAppDbContext dbContext)
: ICommandHandler<SetBumperTemplateAudioCommand, Result>
{
public async Task<Result> Handle(
SetBumperTemplateAudioCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
template.SetAudio(command.Extension, command.DurationSeconds);
return Result.Success();
}
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Отметить загруженную фон-картинку блока (расширение — с точкой).</summary>
public sealed record SetBumperTemplateBackgroundCommand(
Guid ChannelId,
Guid TemplateId,
string Extension
) : ICommand<Result>;
@@ -0,0 +1,29 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbContext)
: ICommandHandler<SetBumperTemplateBackgroundCommand, Result>
{
public async Task<Result> Handle(
SetBumperTemplateBackgroundCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
template.SetBackgroundImage(command.Extension);
return Result.Success();
}
}
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Обновить оформление блока заставки: имя и цвета (в нотации ffmpeg).</summary>
public sealed record UpdateBumperTemplateCommand(
Guid ChannelId,
Guid TemplateId,
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor
) : ICommand<Result>;
@@ -0,0 +1,35 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class UpdateBumperTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateBumperTemplateCommand, Result>
{
public async Task<Result> Handle(
UpdateBumperTemplateCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
template.UpdateStyle(
command.Name.Trim(),
command.BackgroundColor,
command.BackgroundColor2,
command.AccentColor,
command.TextColor
);
return Result.Success();
}
}
@@ -0,0 +1,29 @@
using System.Text.RegularExpressions;
using FluentValidation;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed partial class UpdateBumperTemplateCommandValidator
: AbstractValidator<UpdateBumperTemplateCommand>
{
public UpdateBumperTemplateCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
// Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат
// (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра.
RuleFor(x => x.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage);
RuleFor(x => x.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage);
RuleFor(x => x.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage);
RuleFor(x => x.TextColor).Must(BeSafeColor).WithMessage(ColorMessage);
}
private const string ColorMessage =
"Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white).";
private static bool BeSafeColor(string? value) =>
!string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value);
[GeneratedRegex(@"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$")]
private static partial Regex ColorRegex();
}
@@ -17,8 +17,6 @@ public sealed record ChannelShowDto(
public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
public sealed record ChannelJingleDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight);
public sealed record ProgrammingOverrideDto(
@@ -29,20 +27,29 @@ public sealed record ProgrammingOverrideDto(
IReadOnlyList<OverrideShowDto> Shows
);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. <see cref="BumperTemplateDto"/>).</summary>
public sealed record BumperSettingsDto(
BumperMode Mode,
int DurationSeconds,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor,
BumperFont Font,
string NowLabel,
string NextLabel,
int MinIntervalMinutes,
bool OnlyBetweenDifferentShows,
BumperSelection Selection
);
/// <summary>Блок заставки: своё оформление + звук. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
public sealed record BumperTemplateDto(
Guid Id,
int Position,
bool IsDefault,
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor,
bool HasBackground,
bool HasMusic
bool HasAudio,
double? AudioDurationSeconds
);
public sealed record ChannelDto(
@@ -54,9 +61,9 @@ public sealed record ChannelDto(
int AdsPerBreak,
bool BumpersEnabled,
BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId,
IReadOnlyList<ChannelShowDto> Shows,
IReadOnlyList<ChannelAdDto> Ads,
IReadOnlyList<ChannelJingleDto> Jingles,
IReadOnlyList<ProgrammingOverrideDto> Overrides
);
@@ -36,14 +36,14 @@ public static class ChannelErrors
"Реклама не найдена в пуле канала."
);
public static readonly Error JingleAlreadyAdded = Error.Conflict(
"Channels.JingleAlreadyAdded",
"Этот ролик уже в пуле джинглов канала."
public static readonly Error BumperTemplateNotFound = Error.NotFound(
"Channels.BumperTemplateNotFound",
"Блок заставки не найден."
);
public static readonly Error JingleNotFound = Error.NotFound(
"Channels.JingleNotFound",
жингл не найден в пуле канала."
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
"Channels.CannotRemoveDefaultBumperTemplate",
ефолтный блок заставки удалить нельзя."
);
public static readonly Error AssetNotFound = Error.NotFound(
@@ -16,7 +16,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
var channel = await dbContext.Channels.AsNoTracking()
.Include(c => c.Shows)
.Include(c => c.Ads)
.Include(c => c.Jingles)
.Include(c => c.BumperTemplates)
.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.AsSplitQuery()
@@ -33,10 +33,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId)
.Concat(channel.Jingles.Select(j => j.MediaAssetId))
.Distinct()
.ToList();
var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId).Distinct().ToList();
var assetNames = await dbContext.MediaAssets.AsNoTracking()
.Where(a => poolAssetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
@@ -67,13 +64,20 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
))
.ToList();
var jingles = channel.Jingles
.OrderBy(j => j.Position)
.Select(j => new ChannelJingleDto(
j.Id,
j.MediaAssetId,
assetNames.GetValueOrDefault(j.MediaAssetId),
j.Position
var bumperTemplates = channel.BumperTemplates
.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto(
t.Id,
t.Position,
t.IsDefault,
t.Name,
t.BackgroundColor,
t.BackgroundColor2,
t.AccentColor,
t.TextColor,
t.BackgroundImageExtension is not null,
t.AudioExtension is not null,
t.AudioDurationSeconds
))
.ToList();
@@ -100,24 +104,17 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
channel.AdsPerBreak,
channel.BumpersEnabled,
new BumperSettingsDto(
channel.BumperMode,
channel.BumperDurationSeconds,
channel.BumperBackgroundColor,
channel.BumperBackgroundColor2,
channel.BumperAccentColor,
channel.BumperTextColor,
channel.BumperFont,
channel.BumperNowLabel,
channel.BumperNextLabel,
channel.BumperMinIntervalMinutes,
channel.BumperOnlyBetweenDifferentShows,
channel.BumperBackgroundExtension is not null,
channel.BumperMusicExtension is not null
channel.BumperSelection
),
bumperTemplates,
channel.FillerAssetId,
shows,
ads,
jingles,
overrides
)
);
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelJingle;
public sealed record RemoveChannelJingleCommand(Guid ChannelId, Guid ChannelJingleId)
: ICommand<Result>;
@@ -1,26 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelJingle;
public sealed class RemoveChannelJingleCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveChannelJingleCommand, Result>
{
public async Task<Result> 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);
}
}
@@ -16,8 +16,8 @@ namespace TeleWave.Application.Broadcast.Scheduling;
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) и подставляются
/// как обычные ассеты.
/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) по выбранному
/// блоку и подставляются как обычные ассеты.
/// </summary>
public sealed class ScheduleGenerator(
IAppDbContext dbContext,
@@ -35,6 +35,9 @@ public sealed class ScheduleGenerator(
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
private const int DefaultBumperDurationSeconds = 8;
/// <summary>
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
@@ -49,7 +52,7 @@ public sealed class ScheduleGenerator(
var channel = await dbContext.Channels
.Include(c => c.Shows)
.Include(c => c.Ads)
.Include(c => c.Jingles)
.Include(c => c.BumperTemplates)
.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.AsSplitQuery()
@@ -92,7 +95,7 @@ public sealed class ScheduleGenerator(
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
var result = SchedulePlanner.Plan(input, random);
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана.
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + блоку).
var bumperAssets = await ResolveBumperAssetsAsync(
channel,
result.Entries,
@@ -135,7 +138,7 @@ public sealed class ScheduleGenerator(
channelShow.SetNextEpisodeIndex(idx);
channel.SetNextAdIndex(result.NextAdIndex);
channel.SetNextJingleIndex(result.NextJingleIndex);
channel.SetNextBumperIndex(result.NextBumperIndex);
await dbContext.SaveChangesAsync(cancellationToken);
return added;
@@ -144,57 +147,56 @@ public sealed class ScheduleGenerator(
private static ScheduleEntry? BuildBumperEntry(
Guid channelId,
PlannedEntry entry,
IReadOnlyDictionary<(Guid, Guid), Guid> bumperAssets
IReadOnlyDictionary<(Guid From, Guid To, Guid Template), Guid> bumperAssets
)
{
// Статичный джингл — планировщик уже проставил реальный ассет из пула.
if (entry.MediaAssetId != Guid.Empty)
return ScheduleEntry.Bumper(
channelId,
entry.MediaAssetId,
entry.StartsAtUtc,
entry.EndsAtUtc,
entry.ShowId
);
// Динамическая заставка — ассет резолвится по паре шоу (отрендерен/из кэша).
// Заставка резолвится по паре шоу + выбранному блоку (отрендерена/из кэша). Если рендер не
// удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл).
if (
entry.FromShowId is not { } from
|| entry.ToShowId is not { } to
|| !bumperAssets.TryGetValue((from, to), out var assetId)
|| entry.BumperTemplateId is not { } template
|| !bumperAssets.TryGetValue((from, to, template), out var assetId)
)
// Заставку не удалось отрендерить — пропускаем запись (слот заполнит филлер/следующая
// программа). Планировщик уже учёл её длину, поэтому небольшой зазор допустим.
return null;
return ScheduleEntry.Bumper(channelId, assetId, entry.StartsAtUtc, entry.EndsAtUtc, entry.ToShowId);
}
/// <summary>
/// Для каждой уникальной пары «из→в» из запланированных заставок возвращает id готового
/// Для каждой уникальной тройки «из→в→блок» из запланированных заставок возвращает id готового
/// ассета-заставки: из кэша (<see cref="BumperAsset"/>) либо свежесгенерированного.
/// </summary>
private async Task<Dictionary<(Guid From, Guid To), Guid>> ResolveBumperAssetsAsync(
private async Task<Dictionary<(Guid From, Guid To, Guid Template), Guid>> ResolveBumperAssetsAsync(
Channel channel,
IReadOnlyList<PlannedEntry> entries,
IReadOnlyDictionary<Guid, string> showNames,
CancellationToken cancellationToken
)
{
var result = new Dictionary<(Guid, Guid), Guid>();
var styleSignature = BumperStyleSignature(channel);
var pairs = entries
.Where(e => e.Kind == ScheduleEntryKind.Bumper && e.FromShowId is not null && e.ToShowId is not null)
.Select(e => (From: e.FromShowId!.Value, To: e.ToShowId!.Value))
var result = new Dictionary<(Guid, Guid, Guid), Guid>();
var templatesById = channel.BumperTemplates.ToDictionary(t => t.Id);
var combos = entries
.Where(e =>
e.Kind == ScheduleEntryKind.Bumper
&& e.FromShowId is not null
&& e.ToShowId is not null
&& e.BumperTemplateId is not null
)
.Select(e => (
From: e.FromShowId!.Value,
To: e.ToShowId!.Value,
Template: e.BumperTemplateId!.Value
))
.Distinct()
.ToList();
if (pairs.Count == 0)
if (combos.Count == 0)
return result;
var fromIds = pairs.Select(p => p.From).Distinct().ToList();
var toIds = pairs.Select(p => p.To).Distinct().ToList();
var fromIds = combos.Select(c => c.From).Distinct().ToList();
var toIds = combos.Select(c => c.To).Distinct().ToList();
// Постеры шоу-получателей — как фон заставки (если у канала нет своего фона).
// Постеры шоу-получателей — как фон заставки (если у блока нет своей фон-картинки).
var showIds = fromIds.Concat(toIds).Distinct().ToList();
var posterByShow = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterPath != null)
@@ -220,22 +222,26 @@ public sealed class ScheduleGenerator(
.ToListAsync(cancellationToken);
var readySet = readyAssetIds.ToHashSet();
foreach (var pair in pairs)
foreach (var combo in combos)
{
var fromName = showNames.GetValueOrDefault(pair.From, "…");
var toName = showNames.GetValueOrDefault(pair.To, "…");
var toPosterRel = posterByShow.GetValueOrDefault(pair.To);
var signature = ComputeSignature(fromName, toName, styleSignature, toPosterRel ?? "-");
if (!templatesById.TryGetValue(combo.Template, out var template))
continue;
var fromName = showNames.GetValueOrDefault(combo.From, "");
var toName = showNames.GetValueOrDefault(combo.To, "…");
var toPosterRel = posterByShow.GetValueOrDefault(combo.To);
var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template));
var signature = ComputeSignature(channel, template, fromName, toName, aligned, toPosterRel ?? "-");
var hit = cached.FirstOrDefault(c =>
c.FromShowId == pair.From
&& c.ToShowId == pair.To
c.FromShowId == combo.From
&& c.ToShowId == combo.To
&& c.Signature == signature
&& readySet.Contains(c.MediaAssetId)
);
if (hit is not null)
{
result[pair] = hit.MediaAssetId;
result[combo] = hit.MediaAssetId;
continue;
}
@@ -243,15 +249,17 @@ public sealed class ScheduleGenerator(
{
var assetId = await RenderBumperAsync(
channel,
pair.From,
pair.To,
template,
combo.From,
combo.To,
fromName,
toName,
aligned,
signature,
toPosterRel,
cancellationToken
);
result[pair] = assetId;
result[combo] = assetId;
}
catch (Exception ex)
{
@@ -269,10 +277,12 @@ public sealed class ScheduleGenerator(
private async Task<Guid> RenderBumperAsync(
Channel channel,
BumperTemplate template,
Guid fromShowId,
Guid toShowId,
string fromName,
string toName,
int alignedDurationSeconds,
string signature,
string? toPosterRelative,
CancellationToken cancellationToken
@@ -284,7 +294,7 @@ public sealed class ScheduleGenerator(
: metadataImages.ResolveAbsolutePath(toPosterRelative);
var render = await bumperRenderer.RenderAsync(
asset.Id,
BuildSpec(channel, fromName, toName, posterAbs),
BuildSpec(channel, template, alignedDurationSeconds, fromName, toName, posterAbs),
cancellationToken
);
@@ -308,67 +318,78 @@ public sealed class ScheduleGenerator(
private BumperRenderSpec BuildSpec(
Channel channel,
BumperTemplate template,
int alignedDurationSeconds,
string fromName,
string toName,
string? posterAbsolutePath
) =>
new(
AlignedBumperDuration(channel),
alignedDurationSeconds,
_bumper.Width,
_bumper.Height,
channel.BumperBackgroundColor,
channel.BumperBackgroundColor2,
channel.BumperAccentColor,
channel.BumperTextColor,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
FontPath(channel.BumperFont),
channel.BumperNowLabel,
fromName,
channel.BumperNextLabel,
toName,
bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension),
bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension),
bumperStorage.BackgroundPath(template.Id, template.BackgroundImageExtension),
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterAbsolutePath
);
private string FontPath(BumperFont font) =>
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
/// <summary>Длительность заставки канала, выровненная вверх до кратности сегменту.</summary>
private int AlignedBumperDuration(Channel channel)
/// <summary>Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза.</summary>
private static double TemplateDurationSeconds(BumperTemplate template) =>
template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds;
/// <summary>Длительность, выровненная вверх до кратности длине сегмента (инвариант раздачи).</summary>
private int AlignedDurationSeconds(double seconds)
{
var requested = Math.Max(_segmentSeconds, channel.BumperDurationSeconds);
return (int)(Math.Ceiling((double)requested / _segmentSeconds) * _segmentSeconds);
var requested = Math.Max(_segmentSeconds, seconds);
return (int)(Math.Ceiling(requested / _segmentSeconds) * _segmentSeconds);
}
/// <summary>Сигнатура оформления канала — входит в кэш-ключ, чтобы правка стиля пересобирала заставки.</summary>
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(
/// <summary>
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
/// заставка пересобирается.
/// </summary>
private string ComputeSignature(
Channel channel,
BumperTemplate template,
string fromName,
string toName,
string styleSignature,
int alignedDurationSeconds,
string poster
)
{
var raw = string.Join('', fromName, toName, styleSignature, poster);
var raw = string.Join(
'',
_bumper.TemplateVersion,
_bumper.Width,
_bumper.Height,
alignedDurationSeconds,
channel.BumperFont,
channel.BumperNowLabel,
channel.BumperNextLabel,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
template.Revision,
template.BackgroundImageExtension ?? "-",
template.AudioExtension ?? "-",
fromName,
toName,
poster
);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(hash);
}
@@ -411,7 +432,6 @@ public sealed class ScheduleGenerator(
var candidateAssetIds = episodesByShow.Values
.SelectMany(x => x)
.Concat(channel.Ads.Select(a => a.MediaAssetId))
.Concat(channel.Jingles.Select(j => j.MediaAssetId))
.Distinct()
.ToList();
@@ -452,10 +472,13 @@ public sealed class ScheduleGenerator(
.Where(durations.ContainsKey)
.ToList();
var jinglePool = channel.Jingles
.OrderBy(j => j.Position)
.Select(j => j.MediaAssetId)
.Where(durations.ContainsKey)
// Блоки заставок: длительность слота — по звуку (или дефолт), выровнена на сегмент.
var bumperTemplates = channel.BumperTemplates
.OrderBy(t => t.Position)
.Select(t => new PlannerBumperTemplate(
t.Id,
TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t)))
))
.ToList();
var overrides = channel.Overrides
@@ -469,11 +492,10 @@ public sealed class ScheduleGenerator(
var bumpers = new PlannerBumperConfig(
channel.BumpersEnabled,
TimeSpan.FromSeconds(AlignedBumperDuration(channel)),
channel.BumperOnlyBetweenDifferentShows,
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
channel.BumperMode,
jinglePool
channel.BumperSelection,
bumperTemplates
);
return new PlannerInput(
@@ -488,7 +510,7 @@ public sealed class ScheduleGenerator(
startTime,
horizonEnd,
bumpers,
channel.NextJingleIndex
channel.NextBumperIndex
);
}
}
@@ -15,17 +15,12 @@ public sealed record UpdateChannelSettingsCommand(
Guid? FillerAssetId
) : ICommand<Result>;
/// <summary>Оформление и правила ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
public sealed record BumperSettingsInput(
BumperMode Mode,
int DurationSeconds,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor,
BumperFont Font,
string NowLabel,
string NextLabel,
int MinIntervalMinutes,
bool OnlyBetweenDifferentShows
bool OnlyBetweenDifferentShows,
BumperSelection Selection
);
@@ -36,17 +36,12 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
command.FillerAssetId
);
channel.UpdateBumperSettings(
command.Bumper.Mode,
command.Bumper.DurationSeconds,
command.Bumper.BackgroundColor,
command.Bumper.BackgroundColor2,
command.Bumper.AccentColor,
command.Bumper.TextColor,
command.Bumper.Font,
command.Bumper.NowLabel,
command.Bumper.NextLabel,
command.Bumper.MinIntervalMinutes,
command.Bumper.OnlyBetweenDifferentShows
command.Bumper.OnlyBetweenDifferentShows,
command.Bumper.Selection
);
return Result.Success();
}
@@ -1,9 +1,8 @@
using System.Text.RegularExpressions;
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed partial class UpdateChannelSettingsCommandValidator
public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand>
{
public UpdateChannelSettingsCommandValidator()
@@ -11,25 +10,8 @@ public sealed partial class UpdateChannelSettingsCommandValidator
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10);
RuleFor(x => x.Bumper.DurationSeconds).InclusiveBetween(2, 30);
RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
RuleFor(x => x.Bumper.NowLabel).MaximumLength(64);
RuleFor(x => x.Bumper.NextLabel).MaximumLength(64);
// Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат
// (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра.
RuleFor(x => x.Bumper.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage);
RuleFor(x => x.Bumper.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage);
RuleFor(x => x.Bumper.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage);
RuleFor(x => x.Bumper.TextColor).Must(BeSafeColor).WithMessage(ColorMessage);
}
private const string ColorMessage =
"Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white).";
private static bool BeSafeColor(string? value) =>
!string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value);
[GeneratedRegex(@"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$")]
private static partial Regex ColorRegex();
}
@@ -0,0 +1,8 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Замер длительности аудиофайла (ffprobe). Нужен, чтобы длина заставки шла по длине звука.</summary>
public interface IAudioProbe
{
/// <summary>Длительность файла по абсолютному пути или null, если определить не удалось.</summary>
Task<TimeSpan?> TryGetDurationAsync(string absolutePath, CancellationToken cancellationToken);
}
@@ -1,31 +1,34 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Хранилище сырых файлов шаблона заставки канала (фон и музыка) под bumpers/{channelId}. В отличие
/// Хранилище сырых файлов блоков заставок (звук и фон-картинка) под bumpers/{templateId}. В отличие
/// от обычных ассетов эти файлы НЕ режутся на HLS — они подаются как входы в рендер заставки.
/// </summary>
public interface IBumperTemplateStorage
{
Task SaveAudioAsync(
Guid templateId,
string extension,
Stream content,
CancellationToken cancellationToken
);
Task SaveBackgroundAsync(
Guid channelId,
Guid templateId,
string extension,
Stream content,
CancellationToken cancellationToken
);
Task SaveMusicAsync(
Guid channelId,
string extension,
Stream content,
CancellationToken cancellationToken
);
void DeleteAudio(Guid templateId);
void DeleteBackground(Guid templateId);
void DeleteBackground(Guid channelId);
void DeleteMusic(Guid channelId);
/// <summary>Удалить все файлы блока (при удалении самого блока).</summary>
void DeleteTemplate(Guid templateId);
/// <summary>Абсолютный путь к загруженному фону или null (нет расширения / файл отсутствует).</summary>
string? BackgroundPath(Guid channelId, string? extension);
/// <summary>Абсолютный путь к загруженному звуку или null (нет расширения / файл отсутствует).</summary>
string? AudioPath(Guid templateId, string? extension);
/// <summary>Абсолютный путь к загруженной музыке или null.</summary>
string? MusicPath(Guid channelId, string? extension);
/// <summary>Абсолютный путь к загруженной фон-картинке или null.</summary>
string? BackgroundPath(Guid templateId, string? extension);
}
@@ -1,14 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Какие заставки вставлять на переходах.</summary>
public enum BumperMode
{
/// <summary>Только динамические «Сейчас/Далее», отрисованные по оформлению канала.</summary>
Dynamic,
/// <summary>Только готовые ролики-джинглы из пула канала (по кругу).</summary>
Static,
/// <summary>И то, и другое — чередуя на соседних переходах.</summary>
Both,
}
@@ -0,0 +1,14 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как выбирать блок заставки на каждом переходе между шоу.</summary>
public enum BumperSelection
{
/// <summary>По кругу в порядке блоков (курсор <see cref="Channel.NextBumperIndex"/>).</summary>
Rotation,
/// <summary>Случайный блок на каждом переходе.</summary>
Random,
/// <summary>Всегда первый (дефолтный) блок.</summary>
AlwaysFirst,
}
@@ -0,0 +1,116 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка). На
/// переходе между шоу генератор рендерит «Сейчас/Далее» стилем блока поверх его звука; длительность
/// заставки определяется длиной звука (выравнивается на сегмент при рендере). Общие для канала шрифт,
/// подписи и правила показа живут на <see cref="Channel"/>.
///
/// Первый блок (<see cref="Position"/> == 0) — дефолтный, не удаляется; если звук в нём не загружен,
/// рендер синтезирует джингл по умолчанию.
/// </summary>
public class BumperTemplate
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
/// <summary>Порядковый номер (0 — дефолтный блок). Используется ротацией и как признак дефолта.</summary>
public int Position { get; private set; }
public string Name { get; private set; } = string.Empty;
// ── Оформление блока (цвета — в нотации ffmpeg: 0xRRGGBB или имя) ──
public string BackgroundColor { get; private set; } = DefaultBackgroundColor;
public string BackgroundColor2 { get; private set; } = DefaultBackgroundColor2;
public string AccentColor { get; private set; } = DefaultAccentColor;
public string TextColor { get; private set; } = DefaultTextColor;
/// <summary>Расширение загруженной фон-картинки (с точкой) или null — тогда фон градиент/постер.</summary>
public string? BackgroundImageExtension { get; private set; }
/// <summary>Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл.</summary>
public string? AudioExtension { get; private set; }
/// <summary>Длина загруженного звука в секундах (замер ffprobe) или null, если звука нет.</summary>
public double? AudioDurationSeconds { get; private set; }
/// <summary>Версия файлов блока (звук/фон). Входит в кэш-ключ рендера — замена файла пересобирает заставки.</summary>
public int Revision { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public const string DefaultBackgroundColor = "0x0b1020";
public const string DefaultBackgroundColor2 = "0x1e293b";
public const string DefaultAccentColor = "0x38bdf8";
public const string DefaultTextColor = "white";
public bool IsDefault => Position == 0;
private BumperTemplate() { }
internal static BumperTemplate Create(Guid channelId, int position, string name) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Position = position,
Name = name,
BackgroundColor = DefaultBackgroundColor,
BackgroundColor2 = DefaultBackgroundColor2,
AccentColor = DefaultAccentColor,
TextColor = DefaultTextColor,
BackgroundImageExtension = null,
AudioExtension = null,
AudioDurationSeconds = null,
Revision = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</summary>
public void UpdateStyle(
string name,
string backgroundColor,
string backgroundColor2,
string accentColor,
string textColor
)
{
Name = name;
BackgroundColor = backgroundColor;
BackgroundColor2 = backgroundColor2;
AccentColor = accentColor;
TextColor = textColor;
}
/// <summary>Отметить загруженный звук (extension — с точкой, нижний регистр) и его длину. Меняет ревизию.</summary>
public void SetAudio(string extension, double durationSeconds)
{
AudioExtension = extension;
AudioDurationSeconds = durationSeconds > 0 ? durationSeconds : null;
Revision++;
}
public void ClearAudio()
{
if (AudioExtension is null && AudioDurationSeconds is null)
return;
AudioExtension = null;
AudioDurationSeconds = null;
Revision++;
}
/// <summary>Отметить загруженную фон-картинку (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
public void SetBackgroundImage(string extension)
{
BackgroundImageExtension = extension;
Revision++;
}
public void ClearBackgroundImage()
{
if (BackgroundImageExtension is null)
return;
BackgroundImageExtension = null;
Revision++;
}
}
@@ -10,7 +10,7 @@ public class Channel
private readonly List<ChannelShow> _shows = new();
private readonly List<ChannelAd> _ads = new();
private readonly List<ProgrammingOverride> _overrides = new();
private readonly List<ChannelJingle> _jingles = new();
private readonly List<BumperTemplate> _bumperTemplates = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
@@ -26,28 +26,14 @@ public class Channel
/// <summary>Вставлять ли ТВ-заставки на переходах между разными шоу.</summary>
public bool BumpersEnabled { get; private set; }
/// <summary>Какие заставки вставлять: динамические «Сейчас/Далее», статичные джинглы или оба.</summary>
public BumperMode BumperMode { get; private set; }
// ── Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. BumperTemplates) ──
/// <summary>Расширение загруженного фона (с точкой) или null — тогда синтезируется градиент.</summary>
public string? BumperBackgroundExtension { get; private set; }
/// <summary>Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; }
/// <summary>Расширение загруженной музыки (с точкой) или null — тогда синтезируется джингл.</summary>
public string? BumperMusicExtension { get; private set; }
/// <summary>Курсор ротации блоков заставок.</summary>
public int NextBumperIndex { get; private set; }
/// <summary>Счётчик версии файлов заставки (фон/музыка). Входит в кэш-ключ, чтобы замена файла тем
/// же именем пересобирала уже отрендеренные динамические заставки.</summary>
public int BumperRevision { get; private set; }
/// <summary>Курсор ротации пула джинглов.</summary>
public int NextJingleIndex { get; private set; }
// ── Оформление и правила ТВ-заставок (значения на канал; см. UpdateBumperSettings) ──
public int BumperDurationSeconds { get; private set; }
public string BumperBackgroundColor { get; private set; } = DefaultBackgroundColor;
public string BumperBackgroundColor2 { get; private set; } = DefaultBackgroundColor2;
public string BumperAccentColor { get; private set; } = DefaultAccentColor;
public string BumperTextColor { get; private set; } = DefaultTextColor;
public BumperFont BumperFont { get; private set; }
public string BumperNowLabel { get; private set; } = DefaultNowLabel;
public string BumperNextLabel { get; private set; } = DefaultNextLabel;
@@ -58,13 +44,9 @@ public class Channel
/// <summary>Ставить заставку только на смене шоу (иначе — и внутри марафона одного шоу).</summary>
public bool BumperOnlyBetweenDifferentShows { get; private set; }
private const int DefaultBumperDurationSeconds = 8;
private const string DefaultBackgroundColor = "0x0b1020";
private const string DefaultBackgroundColor2 = "0x1e293b";
private const string DefaultAccentColor = "0x38bdf8";
private const string DefaultTextColor = "white";
private const string DefaultNowLabel = "СЕЙЧАС";
private const string DefaultNextLabel = "ДАЛЕЕ";
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
@@ -80,13 +62,14 @@ public class Channel
public IReadOnlyList<ChannelAd> Ads => _ads;
public IReadOnlyList<ProgrammingOverride> Overrides => _overrides;
/// <summary>Пул джинглов-отбивок; порядок ротации — по <see cref="ChannelJingle.Position"/>.</summary>
public IReadOnlyList<ChannelJingle> Jingles => _jingles;
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
private Channel() { }
public static Channel Create(string name, string slug, DateTimeOffset epochUtc) =>
new()
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
{
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = name,
@@ -96,16 +79,8 @@ public class Channel
AdInsertion = AdInsertion.BetweenBlocks,
AdsPerBreak = 1,
BumpersEnabled = false,
BumperMode = BumperMode.Dynamic,
BumperBackgroundExtension = null,
BumperMusicExtension = null,
BumperRevision = 0,
NextJingleIndex = 0,
BumperDurationSeconds = DefaultBumperDurationSeconds,
BumperBackgroundColor = DefaultBackgroundColor,
BumperBackgroundColor2 = DefaultBackgroundColor2,
BumperAccentColor = DefaultAccentColor,
BumperTextColor = DefaultTextColor,
BumperSelection = BumperSelection.Rotation,
NextBumperIndex = 0,
BumperFont = BumperFont.Sans,
BumperNowLabel = DefaultNowLabel,
BumperNextLabel = DefaultNextLabel,
@@ -114,6 +89,10 @@ public class Channel
NextAdIndex = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
return channel;
}
public void UpdateSettings(
string name,
@@ -132,85 +111,48 @@ public class Channel
FillerAssetId = fillerAssetId;
}
/// <summary>Оформление и правила ТВ-заставок канала. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</summary>
/// <summary>Общие настройки ТВ-заставок канала: шрифт, подписи, правила показа и стратегия выбора блока.</summary>
public void UpdateBumperSettings(
BumperMode mode,
int durationSeconds,
string backgroundColor,
string backgroundColor2,
string accentColor,
string textColor,
BumperFont font,
string nowLabel,
string nextLabel,
int minIntervalMinutes,
bool onlyBetweenDifferentShows
bool onlyBetweenDifferentShows,
BumperSelection selection
)
{
BumperMode = mode;
BumperDurationSeconds = durationSeconds;
BumperBackgroundColor = backgroundColor;
BumperBackgroundColor2 = backgroundColor2;
BumperAccentColor = accentColor;
BumperTextColor = textColor;
BumperFont = font;
BumperNowLabel = nowLabel;
BumperNextLabel = nextLabel;
BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes);
BumperOnlyBetweenDifferentShows = onlyBetweenDifferentShows;
BumperSelection = selection;
}
/// <summary>Отметить загруженный фон (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
public void SetBumperBackground(string extension)
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
public BumperTemplate AddBumperTemplate(string name)
{
BumperBackgroundExtension = extension;
BumperRevision++;
var nextPosition = _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
var template = BumperTemplate.Create(Id, nextPosition, name);
_bumperTemplates.Add(template);
return template;
}
public void ClearBumperBackground()
{
if (BumperBackgroundExtension is null)
return;
BumperBackgroundExtension = null;
BumperRevision++;
}
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
/// <summary>Отметить загруженную музыку (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
public void SetBumperMusic(string extension)
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
public bool RemoveBumperTemplate(Guid templateId)
{
BumperMusicExtension = extension;
BumperRevision++;
}
public void ClearBumperMusic()
{
if (BumperMusicExtension is null)
return;
BumperMusicExtension = null;
BumperRevision++;
}
public ChannelJingle AddJingle(Guid mediaAssetId)
{
var nextPosition = _jingles.Count == 0 ? 0 : _jingles.Max(j => j.Position) + 1;
var jingle = ChannelJingle.Create(Id, mediaAssetId, nextPosition);
_jingles.Add(jingle);
return jingle;
}
public bool RemoveJingle(Guid channelJingleId)
{
var jingle = _jingles.FirstOrDefault(j => j.Id == channelJingleId);
if (jingle is null)
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
if (template is null || template.IsDefault)
return false;
_jingles.Remove(jingle);
_bumperTemplates.Remove(template);
return true;
}
public bool HasJingle(Guid mediaAssetId) => _jingles.Any(j => j.MediaAssetId == mediaAssetId);
/// <summary>Планировщик двигает курсор пула джинглов по мере вставки отбивок.</summary>
public void SetNextJingleIndex(int index) => NextJingleIndex = index;
/// <summary>Планировщик двигает курсор ротации блоков заставок по мере вставки.</summary>
public void SetNextBumperIndex(int index) => NextBumperIndex = index;
public ChannelShow? FindShow(Guid channelShowId) => _shows.FirstOrDefault(s => s.Id == channelShowId);
@@ -1,22 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Готовый ролик-джингл (отбивка) в пуле канала. Крутятся по кругу в порядке <see cref="Position"/>
/// на переходах между шоу, когда режим заставок — Static или Both.</summary>
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,
};
}
@@ -19,18 +19,17 @@ public static class SchedulePlanner
var byShowId = input.Shows.ToDictionary(s => s.ShowId);
var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex);
var nextAd = input.NextAdIndex;
var nextJingle = input.NextJingleIndex;
var nextBumper = input.NextBumperIndex;
// Есть ли вообще из чего строить эфир.
var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0);
if (!anyPlayable)
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
var cursor = input.StartTime;
var iterations = 0;
Guid? prevShowId = null;
DateTimeOffset? lastBumperAt = null;
var bumperCount = 0;
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
{
@@ -40,8 +39,8 @@ public static class SchedulePlanner
var pick = WeightedPick(candidates, random);
// ТВ-заставка на переходе. Динамическую (Сейчас/Далее) резервируем слотом фикс. длины —
// ассет отрендерит оркестратор; статичный джингл берём готовым из пула (реальная длина).
// ТВ-заставка на переходе. Резервируем слот выбранного блока фикс. длины — конкретный
// отрендеренный ассет («Сейчас/Далее» стилем блока поверх его звука) подставит оркестратор.
if (
prevShowId is { } prev
&& input.Bumpers is { Enabled: true } bumper
@@ -54,11 +53,8 @@ public static class SchedulePlanner
)
{
var bumperStart = cursor;
if (TryPlaceBumper(entries, bumper, bumperCount, prev, pick.ShowId, input, ref nextJingle, ref cursor))
{
if (TryPlaceBumper(entries, bumper, prev, pick.ShowId, random, ref nextBumper, ref cursor))
lastBumperAt = bumperStart;
bumperCount++;
}
}
var blockStart = cursor;
@@ -94,75 +90,62 @@ public static class SchedulePlanner
prevShowId = pick.ShowId;
}
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
}
/// <summary>
/// Ставит одну заставку на переходе по режиму канала. Динамическая — плейсхолдер фикс. длины
/// (ассет подставит оркестратор). Статичная — готовый джингл из пула (реальная длина, курсор
/// двигается). В режиме Both типы чередуются; при пустом пуле Both уходит в динамику.
/// Возвращает true, если заставка добавлена (курсор сдвинут).
/// Ставит на переходе заставку выбранного блока: резервирует слот его длины и оставляет
/// плейсхолдер с парой шоу + id блока (ассет отрендерит оркестратор). Выбор блока — по стратегии
/// канала (ротация двигает курсор). Возвращает true, если заставка добавлена (курсор сдвинут).
/// </summary>
private static bool TryPlaceBumper(
List<PlannedEntry> entries,
PlannerBumperConfig bumper,
int bumperCount,
Guid fromShowId,
Guid toShowId,
PlannerInput input,
ref int nextJingle,
IRandomSource random,
ref int nextBumper,
ref DateTimeOffset cursor
)
{
var pool = bumper.JinglePool;
var hasPool = pool is { Count: > 0 };
var wantStatic =
bumper.Mode == BumperMode.Static
|| (bumper.Mode == BumperMode.Both && bumperCount % 2 == 1);
// В режиме Both при пустом пуле показываем динамику.
if (wantStatic && !hasPool && bumper.Mode == BumperMode.Both)
wantStatic = false;
if (wantStatic)
{
if (!hasPool)
return false; // Static без пула — вставлять нечего.
var idx = ((nextJingle % pool!.Count) + pool.Count) % pool.Count;
var assetId = pool[idx];
nextJingle++;
var dur = DurationOf(assetId, input);
if (dur <= TimeSpan.Zero)
return false;
var end = cursor + dur;
entries.Add(
new PlannedEntry(assetId, ScheduleEntryKind.Bumper, cursor, end, null, null)
);
cursor = end;
return true;
}
// Динамическая заставка «Сейчас/Далее» — плейсхолдер с парой шоу для рендера.
if (bumper.Duration <= TimeSpan.Zero)
var templates = bumper.Templates;
if (templates is not { Count: > 0 })
return false;
var dynEnd = cursor + bumper.Duration;
PlannerBumperTemplate template;
switch (bumper.Selection)
{
case BumperSelection.Random:
template = templates[random.Next(templates.Count)];
break;
case BumperSelection.AlwaysFirst:
template = templates[0];
break;
default: // Rotation
var idx = ((nextBumper % templates.Count) + templates.Count) % templates.Count;
template = templates[idx];
nextBumper++;
break;
}
if (template.Duration <= TimeSpan.Zero)
return false;
var end = cursor + template.Duration;
entries.Add(
new PlannedEntry(
Guid.Empty,
ScheduleEntryKind.Bumper,
cursor,
dynEnd,
end,
toShowId,
null,
fromShowId,
toShowId
toShowId,
template.TemplateId
)
);
cursor = dynEnd;
cursor = end;
return true;
}
@@ -22,19 +22,22 @@ public sealed record PlannerOverride(
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
/// <summary>
/// Политика ТВ-заставок на переходах. <see cref="Duration"/> должна быть кратна длине сегмента
/// (генератор выравнивает). Планировщик резервирует под заставку слот этой длины, а конкретный
/// сгенерированный ассет подставляет уже оркестратор.
/// Политика ТВ-заставок на переходах. Планировщик выбирает блок (<see cref="Templates"/>) по
/// стратегии <see cref="Selection"/> и резервирует слот его длины (<see cref="PlannerBumperTemplate.Duration"/>,
/// уже выровнена генератором на сегмент). Конкретный отрендеренный ассет подставляет оркестратор
/// по паре шоу + выбранному блоку.
/// </summary>
public sealed record PlannerBumperConfig(
bool Enabled,
TimeSpan Duration,
bool OnlyBetweenDifferentShows,
TimeSpan MinInterval,
BumperMode Mode = BumperMode.Dynamic,
IReadOnlyList<Guid>? JinglePool = null
BumperSelection Selection,
IReadOnlyList<PlannerBumperTemplate> Templates
);
/// <summary>Блок заставки в терминах планировщика: id + длительность слота (кратна сегменту).</summary>
public sealed record PlannerBumperTemplate(Guid TemplateId, TimeSpan Duration);
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
public sealed record PlannerInput(
Guid ChannelId,
@@ -48,13 +51,14 @@ public sealed record PlannerInput(
DateTimeOffset StartTime,
DateTimeOffset HorizonEnd,
PlannerBumperConfig? Bumpers = null,
int NextJingleIndex = 0
int NextBumperIndex = 0
);
/// <summary>
/// Одна запланированная запись (ещё не доменная сущность). Для заставок (<see cref="Kind"/> ==
/// <see cref="ScheduleEntryKind.Bumper"/>) <see cref="MediaAssetId"/> пуст — его подставит
/// оркестратор после рендера по паре (<see cref="FromShowId"/> → <see cref="ToShowId"/>).
/// оркестратор после рендера по паре (<see cref="FromShowId"/> → <see cref="ToShowId"/>) и выбранному
/// блоку (<see cref="BumperTemplateId"/>).
/// </summary>
public sealed record PlannedEntry(
Guid MediaAssetId,
@@ -64,13 +68,14 @@ public sealed record PlannedEntry(
Guid? ShowId,
int? EpisodeIndex,
Guid? FromShowId = null,
Guid? ToShowId = null
Guid? ToShowId = null,
Guid? BumperTemplateId = null
);
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы).</summary>
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow, рекламы, заставок).</summary>
public sealed record PlannerResult(
IReadOnlyList<PlannedEntry> Entries,
IReadOnlyDictionary<Guid, int> NextEpisodeIndexByChannelShow,
int NextAdIndex,
int NextJingleIndex
int NextBumperIndex
);
@@ -136,6 +136,7 @@ public static class DependencyInjection
services.AddSingleton<MediaPathResolver>();
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
@@ -3,67 +3,74 @@ using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Файловое хранилище шаблонов заставок: сырые фон/музыка под bumpers/{channelId}/{kind}{ext}.
/// На канал — не более одного файла каждого вида (при загрузке старый удаляется).
/// Файловое хранилище блоков заставок: сырые звук/фон под bumpers/{templateId}/{kind}{ext}.
/// На блок — не более одного файла каждого вида (при загрузке старый удаляется).
/// </summary>
public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemplateStorage
{
private const string Audio = "audio";
private const string Background = "background";
private const string Music = "music";
public Task SaveAudioAsync(
Guid templateId,
string extension,
Stream content,
CancellationToken cancellationToken
) => SaveAsync(templateId, Audio, extension, content, cancellationToken);
public Task SaveBackgroundAsync(
Guid channelId,
Guid templateId,
string extension,
Stream content,
CancellationToken cancellationToken
) => SaveAsync(channelId, Background, extension, content, cancellationToken);
) => SaveAsync(templateId, Background, extension, content, cancellationToken);
public Task SaveMusicAsync(
Guid channelId,
string extension,
Stream content,
CancellationToken cancellationToken
) => SaveAsync(channelId, Music, extension, content, cancellationToken);
public void DeleteAudio(Guid templateId) => DeleteKind(templateId, Audio);
public void DeleteBackground(Guid channelId) => DeleteKind(channelId, Background);
public void DeleteBackground(Guid templateId) => DeleteKind(templateId, Background);
public void DeleteMusic(Guid channelId) => DeleteKind(channelId, Music);
public void DeleteTemplate(Guid templateId)
{
var dir = paths.BumperTemplateDir(templateId);
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
public string? BackgroundPath(Guid channelId, string? extension) =>
ResolvePath(channelId, Background, extension);
public string? AudioPath(Guid templateId, string? extension) =>
ResolvePath(templateId, Audio, extension);
public string? MusicPath(Guid channelId, string? extension) =>
ResolvePath(channelId, Music, extension);
public string? BackgroundPath(Guid templateId, string? extension) =>
ResolvePath(templateId, Background, extension);
private async Task SaveAsync(
Guid channelId,
Guid templateId,
string kind,
string extension,
Stream content,
CancellationToken cancellationToken
)
{
var dir = paths.BumperChannelDir(channelId);
var dir = paths.BumperTemplateDir(templateId);
Directory.CreateDirectory(dir);
RemoveExisting(dir, kind);
var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension));
var path = paths.BumperTemplateFilePath(templateId, kind, NormalizeExtension(extension));
await using var fs = File.Create(path);
await content.CopyToAsync(fs, cancellationToken);
}
private void DeleteKind(Guid channelId, string kind)
private void DeleteKind(Guid templateId, string kind)
{
var dir = paths.BumperChannelDir(channelId);
var dir = paths.BumperTemplateDir(templateId);
if (Directory.Exists(dir))
RemoveExisting(dir, kind);
}
private string? ResolvePath(Guid channelId, string kind, string? extension)
private string? ResolvePath(Guid templateId, string kind, string? extension)
{
if (string.IsNullOrWhiteSpace(extension))
return null;
var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension));
var path = paths.BumperTemplateFilePath(templateId, kind, NormalizeExtension(extension));
return File.Exists(path) ? path : null;
}
@@ -0,0 +1,56 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Замер длительности аудиофайла через ffprobe (format.duration). Используется при загрузке звука
/// блока заставки, чтобы длина заставки шла по длине звука.
/// </summary>
public sealed class FfprobeAudioProbe(IOptions<MediaOptions> mediaOptions) : IAudioProbe
{
private readonly MediaOptions _media = mediaOptions.Value;
public async Task<TimeSpan?> TryGetDurationAsync(
string absolutePath,
CancellationToken cancellationToken
)
{
if (!File.Exists(absolutePath))
return null;
try
{
var result = await ProcessRunner.RunAsync(
_media.FfprobePath,
["-v", "quiet", "-print_format", "json", "-show_format", absolutePath],
lowPriority: false,
cancellationToken
);
if (result.ExitCode != 0)
return null;
using var doc = JsonDocument.Parse(result.StdOut);
if (
doc.RootElement.TryGetProperty("format", out var format)
&& format.TryGetProperty("duration", out var durEl)
&& double.TryParse(
durEl.GetString(),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var seconds
)
&& seconds > 0
)
return TimeSpan.FromSeconds(seconds);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
// Не удалось разобрать вывод ffprobe — длину не знаем.
}
return null;
}
}
@@ -79,12 +79,12 @@ public sealed class MediaPathResolver
}
}
public string BumperChannelDir(Guid channelId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, channelId.ToString("N")));
public string BumperTemplateDir(Guid templateId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
/// <summary>Путь к файлу шаблона заставки (kind — «background»/«music», extension — с точкой).</summary>
public string BumperFilePath(Guid channelId, string kind, string extension) =>
EnsureWithinRoot(Path.Combine(BumpersDir, channelId.ToString("N"), kind + extension));
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
public string OriginalPath(Guid assetId, string extension) =>
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
@@ -0,0 +1,859 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeleWave.Infrastructure.Persistence;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260725074209_BumperTemplates")]
partial class BumperTemplates
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<double?>("AudioDurationSeconds")
.HasColumnType("double precision");
b.Property<string>("AudioExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("BackgroundColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundColor2")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundImageExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("TextColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("BumperTemplate");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<string>("BumperNextLabel")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperNowLabel")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumperOnlyBetweenDifferentShows")
.HasColumnType("boolean");
b.Property<int>("BumperSelection")
.HasColumnType("integer");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("MetadataExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MetadataProvider")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PosterPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateOnly?>("AirDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Episode")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Overview")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int?>("Season")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<string>("StillPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("MediaAssetId");
b.HasIndex("ShowId", "Position");
b.ToTable("ShowEpisode");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("BumperTemplates")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Shows")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
.WithMany("Shows")
.HasForeignKey("ProgrammingOverrideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Overrides")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.HasOne("TeleWave.Domain.Library.Show", null)
.WithMany("Episodes")
.HasForeignKey("ShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("Ads");
b.Navigation("BumperTemplates");
b.Navigation("Overrides");
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,216 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelJingle");
migrationBuilder.DropColumn(
name: "BumperAccentColor",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperBackgroundColor",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperBackgroundColor2",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperBackgroundExtension",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperDurationSeconds",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperMode",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperMusicExtension",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperTextColor",
table: "Channels");
migrationBuilder.RenameColumn(
name: "NextJingleIndex",
table: "Channels",
newName: "NextBumperIndex");
// Ревизия файлов уехала на блоки заставок — старый счётчик не переносим, стратегия по
// умолчанию Rotation (0).
migrationBuilder.DropColumn(
name: "BumperRevision",
table: "Channels");
migrationBuilder.AddColumn<int>(
name: "BumperSelection",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "BumperTemplate",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
BackgroundColor = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
BackgroundColor2 = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
AccentColor = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
TextColor = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
BackgroundImageExtension = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
AudioExtension = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
AudioDurationSeconds = table.Column<double>(type: "double precision", nullable: true),
Revision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BumperTemplate", x => x.Id);
table.ForeignKey(
name: "FK_BumperTemplate_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BumperTemplate_ChannelId_Position",
table: "BumperTemplate",
columns: new[] { "ChannelId", "Position" });
// Каждому существующему каналу — дефолтный блок заставки (Position 0, без звука/фона).
migrationBuilder.Sql(
"""
INSERT INTO "BumperTemplate"
("Id", "ChannelId", "Position", "Name", "BackgroundColor", "BackgroundColor2",
"AccentColor", "TextColor", "Revision", "CreatedAt")
SELECT gen_random_uuid(), c."Id", 0, 'Заставка 1', '0x0b1020', '0x1e293b',
'0x38bdf8', 'white', 0, now()
FROM "Channels" c;
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BumperTemplate");
migrationBuilder.RenameColumn(
name: "NextBumperIndex",
table: "Channels",
newName: "NextJingleIndex");
migrationBuilder.DropColumn(
name: "BumperSelection",
table: "Channels");
migrationBuilder.AddColumn<int>(
name: "BumperRevision",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "BumperAccentColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor2",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundExtension",
table: "Channels",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "BumperDurationSeconds",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BumperMode",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "BumperMusicExtension",
table: "Channels",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BumperTextColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "ChannelJingle",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(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" });
}
}
}
@@ -188,6 +188,66 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<double?>("AudioDurationSeconds")
.HasColumnType("double precision");
b.Property<string>("AudioExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("BackgroundColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundColor2")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundImageExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("TextColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("BumperTemplate");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
@@ -199,36 +259,12 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<string>("BumperAccentColor")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperBackgroundColor")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperBackgroundColor2")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperBackgroundExtension")
.HasColumnType("text");
b.Property<int>("BumperDurationSeconds")
.HasColumnType("integer");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<int>("BumperMode")
.HasColumnType("integer");
b.Property<string>("BumperMusicExtension")
.HasColumnType("text");
b.Property<string>("BumperNextLabel")
.IsRequired()
.HasColumnType("text");
@@ -240,13 +276,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<bool>("BumperOnlyBetweenDifferentShows")
.HasColumnType("boolean");
b.Property<int>("BumperRevision")
b.Property<int>("BumperSelection")
.HasColumnType("integer");
b.Property<string>("BumperTextColor")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
@@ -270,7 +302,7 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextJingleIndex")
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<string>("Slug")
@@ -307,27 +339,6 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelJingle");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
@@ -765,19 +776,19 @@ namespace TeleWave.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.WithMany("BumperTemplates")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Jingles")
.WithMany("Ads")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
@@ -823,7 +834,7 @@ namespace TeleWave.Infrastructure.Migrations
{
b.Navigation("Ads");
b.Navigation("Jingles");
b.Navigation("BumperTemplates");
b.Navigation("Overrides");
@@ -28,11 +28,11 @@ public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
builder.Navigation(x => x.Ads).UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.HasMany(x => x.Jingles)
.HasMany(x => x.BumperTemplates)
.WithOne()
.HasForeignKey(j => j.ChannelId)
.HasForeignKey(t => t.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Jingles).UsePropertyAccessMode(PropertyAccessMode.Field);
builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.HasMany(x => x.Overrides)
@@ -59,11 +59,18 @@ public class ChannelAdConfiguration : IEntityTypeConfiguration<ChannelAd>
}
}
public class ChannelJingleConfiguration : IEntityTypeConfiguration<ChannelJingle>
public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate>
{
public void Configure(EntityTypeBuilder<ChannelJingle> builder)
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.BackgroundImageExtension).HasMaxLength(16);
builder.Property(x => x.AudioExtension).HasMaxLength(16);
}
}
@@ -20,6 +20,26 @@ public class SchedulePlannerTests
private static Dictionary<Guid, TimeSpan> Durations(params (Guid Id, int Minutes)[] items) =>
items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes));
private static readonly Guid DefaultTemplate = Guid.NewGuid();
/// <summary>Конфиг заставок с одним дефолтным блоком (8с), если явно не заданы блоки.</summary>
private static PlannerBumperConfig Bumper(
bool enabled,
bool onlyBetweenDifferentShows,
TimeSpan minInterval,
BumperSelection selection = BumperSelection.Rotation,
params PlannerBumperTemplate[] templates
) =>
new(
enabled,
onlyBetweenDifferentShows,
minInterval,
selection,
templates.Length == 0
? [new PlannerBumperTemplate(DefaultTemplate, TimeSpan.FromSeconds(8))]
: templates
);
[Fact]
public void Count_Block_ProducesConsecutiveEpisodes_BackToBack()
{
@@ -212,7 +232,7 @@ public class SchedulePlannerTests
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
{
Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.Zero),
};
// Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2).
@@ -228,6 +248,7 @@ public class SchedulePlannerTests
Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор
Assert.Equal(a.ShowId, first.FromShowId);
Assert.Equal(b.ShowId, first.ToShowId);
Assert.Equal(DefaultTemplate, first.BumperTemplateId);
Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc);
// Встык: программа → заставка → программа.
Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc);
@@ -246,7 +267,7 @@ public class SchedulePlannerTests
horizonEnd: Start.AddMinutes(50)
) with
{
Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.Zero),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
@@ -266,7 +287,7 @@ public class SchedulePlannerTests
horizonEnd: Start.AddMinutes(50)
) with
{
Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: false, MinInterval: TimeSpan.Zero),
Bumpers = Bumper(true, onlyBetweenDifferentShows: false, TimeSpan.Zero),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
@@ -284,12 +305,7 @@ public class SchedulePlannerTests
// Два перехода в горизонте (~на 20-й и ~40-й минуте), но интервал 30 мин пропускает второй.
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
{
Bumpers = new PlannerBumperConfig(
true,
TimeSpan.FromSeconds(8),
OnlyBetweenDifferentShows: true,
MinInterval: TimeSpan.FromMinutes(30)
),
Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.FromMinutes(30)),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
@@ -298,37 +314,70 @@ public class SchedulePlannerTests
}
[Fact]
public void Bumpers_StaticMode_UsesJinglesFromPoolInRotation()
public void Bumpers_Rotation_CyclesTemplatesAndAdvancesCursor()
{
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
Guid j0 = Guid.NewGuid(),
j1 = Guid.NewGuid();
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1), (j1, 1));
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
Guid t0 = Guid.NewGuid(),
t1 = Guid.NewGuid();
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
{
Bumpers = new PlannerBumperConfig(
Bumpers = Bumper(
true,
TimeSpan.FromSeconds(8),
OnlyBetweenDifferentShows: true,
MinInterval: TimeSpan.Zero,
Mode: BumperMode.Static,
JinglePool: [j0, j1]
onlyBetweenDifferentShows: true,
TimeSpan.Zero,
BumperSelection.Rotation,
new PlannerBumperTemplate(t0, TimeSpan.FromSeconds(8)),
new PlannerBumperTemplate(t1, TimeSpan.FromSeconds(4))
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
Assert.Equal(2, bumpers.Count);
Assert.Equal([j0, j1], bumpers.Select(e => e.MediaAssetId)); // ротация пула
Assert.All(bumpers, e => Assert.Null(e.FromShowId)); // статик — не по паре шоу
Assert.Equal(2, result.NextJingleIndex);
Assert.True(bumpers.Count >= 2);
// Ротация: первый блок → t0 (8с), второй → t1 (4с). Все — плейсхолдеры по паре шоу.
Assert.Equal(t0, bumpers[0].BumperTemplateId);
Assert.Equal(TimeSpan.FromSeconds(8), bumpers[0].EndsAtUtc - bumpers[0].StartsAtUtc);
Assert.Equal(t1, bumpers[1].BumperTemplateId);
Assert.Equal(TimeSpan.FromSeconds(4), bumpers[1].EndsAtUtc - bumpers[1].StartsAtUtc);
Assert.All(bumpers, e => Assert.Equal(Guid.Empty, e.MediaAssetId));
Assert.Equal(bumpers.Count, result.NextBumperIndex);
}
[Fact]
public void Bumpers_StaticMode_EmptyPool_NoBumpers()
public void Bumpers_AlwaysFirst_AlwaysUsesFirstTemplate()
{
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
Guid t0 = Guid.NewGuid(),
t1 = Guid.NewGuid();
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
{
Bumpers = Bumper(
true,
onlyBetweenDifferentShows: true,
TimeSpan.Zero,
BumperSelection.AlwaysFirst,
new PlannerBumperTemplate(t0, TimeSpan.FromSeconds(8)),
new PlannerBumperTemplate(t1, TimeSpan.FromSeconds(4))
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
Assert.True(bumpers.Count >= 2);
Assert.All(bumpers, e => Assert.Equal(t0, e.BumperTemplateId));
Assert.Equal(0, result.NextBumperIndex); // курсор ротации не двигается
}
[Fact]
public void Bumpers_NoTemplates_NoBumpers()
{
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
@@ -338,11 +387,10 @@ public class SchedulePlannerTests
{
Bumpers = new PlannerBumperConfig(
true,
TimeSpan.FromSeconds(8),
OnlyBetweenDifferentShows: true,
MinInterval: TimeSpan.Zero,
Mode: BumperMode.Static,
JinglePool: []
Selection: BumperSelection.Rotation,
Templates: []
),
};
@@ -351,35 +399,6 @@ public class SchedulePlannerTests
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_BothMode_AlternatesDynamicAndStatic()
{
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var j0 = Guid.NewGuid();
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1));
// Три перехода: динамика, джингл, динамика.
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
{
Bumpers = new PlannerBumperConfig(
true,
TimeSpan.FromSeconds(8),
OnlyBetweenDifferentShows: true,
MinInterval: TimeSpan.Zero,
Mode: BumperMode.Both,
JinglePool: [j0]
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
Assert.True(bumpers.Count >= 2);
Assert.Equal(Guid.Empty, bumpers[0].MediaAssetId); // первый — динамический (плейсхолдер)
Assert.Equal(j0, bumpers[1].MediaAssetId); // второй — статичный джингл
}
[Fact]
public void Bumpers_Disabled_ProduceNoBumperEntries()
{
@@ -389,7 +408,7 @@ public class SchedulePlannerTests
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
{
Bumpers = new PlannerBumperConfig(false, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
Bumpers = Bumper(false, onlyBetweenDifferentShows: true, TimeSpan.Zero),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
@@ -1,15 +1,17 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { type ReactNode, useEffect, useState } from 'react'
import Hls from 'hls.js'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronLeft, RefreshCw } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { getAccessToken, HttpError } from '@/shared/api/client'
import type {
AdInsertion,
BlockMode,
BumperFont,
BumperMode,
BumperSelection,
BumperSettings,
BumperTemplateDto,
ChannelShowDto,
OverrideMode,
ScheduleEntryDto,
@@ -24,23 +26,26 @@ import { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api'
import { listShows } from '@/features/admin/shows/api'
import {
addBumperTemplate,
addChannelAd,
addChannelJingle,
addChannelShow,
clearBumperBackground,
clearBumperMusic,
bumperPreviewPlaylistUrl,
clearBumperTemplateAudio,
clearBumperTemplateBackground,
createOverride,
deleteOverride,
getChannel,
getSchedule,
regenerateSchedule,
removeBumperTemplate,
removeChannelAd,
removeChannelJingle,
removeChannelShow,
renderBumperPreview,
updateBumperTemplate,
updateChannelSettings,
updateChannelShow,
uploadBumperBackground,
uploadBumperMusic,
uploadBumperTemplateAudio,
uploadBumperTemplateBackground,
} from './api'
function formatTime(iso: string) {
@@ -117,6 +122,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<SettingsCard channel={channel} readyAssets={ready?.items ?? []} onSaved={invalidate} onError={onError} />
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
{/* Шоу канала */}
<CollapsibleCard title={t('admin.channels.shows')} contentClassName="flex flex-col gap-3">
<AddShowForm
@@ -183,34 +190,6 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</ul>
</CollapsibleCard>
{/* Джинглы-отбивки (статичные заставки) */}
<CollapsibleCard title={t('admin.channels.jingles')} contentClassName="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">{t('admin.channels.jinglesHint')}</p>
<AddJingleForm
channelId={channelId}
options={(ready?.items ?? [])
.filter((a) => !channel.jingles.some((j) => j.mediaAssetId === a.id))
.map((a) => ({ id: a.id, name: a.originalFileName }))}
onAdded={invalidate}
onError={onError}
/>
<ul className="flex flex-col divide-y divide-border">
{channel.jingles.map((j) => (
<li key={j.id} className="flex items-center justify-between py-2 text-sm">
<span>{j.assetName ?? '—'}</span>
<RemoveButton
onClick={() =>
removeChannelJingle(channelId, j.id).then(invalidate).catch(onError)
}
/>
</li>
))}
{channel.jingles.length === 0 && (
<li className="py-2 text-muted-foreground">{t('admin.channels.noJingles')}</li>
)}
</ul>
</CollapsibleCard>
{/* Override'ы / марафоны */}
<CollapsibleCard title={t('admin.channels.overrides')} contentClassName="flex flex-col gap-3">
<OverrideForm
@@ -293,20 +272,13 @@ function SettingsCard({
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
const [adInsertion, setAdInsertion] = useState<AdInsertion>(channel.adInsertion)
const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak)
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
const setBumperField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
setBumper((prev) => ({ ...prev, [key]: value }))
useEffect(() => {
setName(channel.name)
setIsEnabled(channel.isEnabled)
setAdInsertion(channel.adInsertion)
setAdsPerBreak(channel.adsPerBreak)
setBumpersEnabled(channel.bumpersEnabled)
setBumper(channel.bumper)
setFillerAssetId(channel.fillerAssetId ?? '')
}, [channel])
@@ -317,8 +289,9 @@ function SettingsCard({
isEnabled,
adInsertion,
adsPerBreak,
bumpersEnabled,
bumper,
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
bumpersEnabled: channel.bumpersEnabled,
bumper: channel.bumper,
fillerAssetId: fillerAssetId || null,
}),
onSuccess: () => {
@@ -384,31 +357,6 @@ function SettingsCard({
/>
{t('admin.channels.enabledLabel')}
</label>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={bumpersEnabled}
onChange={(e) => setBumpersEnabled(e.target.checked)}
/>
<span>
{t('admin.channels.bumpersLabel')}
<span className="block text-xs text-muted-foreground">
{t('admin.channels.bumpersHint')}
</span>
</span>
</label>
{bumpersEnabled && (
<div className="sm:col-span-2">
<BumperSettingsFields
channelId={channel.id}
bumper={bumper}
setField={setBumperField}
onSaved={onSaved}
onError={onError}
/>
</div>
)}
<div className="flex items-end justify-end sm:col-span-2">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
@@ -425,68 +373,92 @@ function cssColor(value: string): string {
return v
}
function BumperSettingsFields({
channelId,
bumper,
setField,
function BumperCard({
channel,
onSaved,
onError,
}: {
channelId: string
bumper: BumperSettings
setField: <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) => void
channel: import('@/shared/api/types').ChannelDto
onSaved: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
const colors: { key: keyof BumperSettings; label: string }[] = [
{ key: 'backgroundColor', label: t('admin.channels.bumperBg') },
{ key: 'backgroundColor2', label: t('admin.channels.bumperBg2') },
{ key: 'accentColor', label: t('admin.channels.bumperAccent') },
{ key: 'textColor', label: t('admin.channels.bumperText') },
]
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
setBumper((prev) => ({ ...prev, [key]: value }))
// Оформление/подписи/шрифт нужны только динамическим заставкам.
const showDynamicStyle = bumper.mode !== 'Static'
useEffect(() => {
setBumpersEnabled(channel.bumpersEnabled)
setBumper(channel.bumper)
}, [channel])
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
// поля берём из канала без изменений (они правятся в своей карточке).
const save = useMutation({
mutationFn: () =>
updateChannelSettings(channel.id, {
name: channel.name,
isEnabled: channel.isEnabled,
adInsertion: channel.adInsertion,
adsPerBreak: channel.adsPerBreak,
bumpersEnabled,
bumper,
fillerAssetId: channel.fillerAssetId,
}),
onSuccess: () => {
toast.success(t('settings.saved'))
onSaved()
},
onError,
})
const addTemplate = useMutation({
mutationFn: () => addBumperTemplate(channel.id, ''),
onSuccess: onSaved,
onError,
})
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
return (
<div className="flex flex-col gap-4 rounded-md border border-border bg-muted/30 p-4">
<p className="text-sm font-medium">{t('admin.channels.bumperStyle')}</p>
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={bumpersEnabled}
onChange={(e) => setBumpersEnabled(e.target.checked)}
/>
<span>
{t('admin.channels.bumpersLabel')}
<span className="block text-xs text-muted-foreground">
{t('admin.channels.bumpersHint')}
</span>
</span>
</label>
{/* Общие настройки */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperMode')}</Label>
<Select value={bumper.mode} onValueChange={(v) => setField('mode', v as BumperMode)}>
<Label>{t('admin.channels.bumperSelection')}</Label>
<Select
value={bumper.selection}
onValueChange={(v) => setField('selection', v as BumperSelection)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Dynamic">{t('admin.channels.bumperModeDynamic')}</SelectItem>
<SelectItem value="Static">{t('admin.channels.bumperModeStatic')}</SelectItem>
<SelectItem value="Both">{t('admin.channels.bumperModeBoth')}</SelectItem>
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
<SelectItem value="AlwaysFirst">
{t('admin.channels.bumperSelectionAlwaysFirst')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperDuration')}</Label>
<Input
type="number"
min={2}
max={30}
value={bumper.durationSeconds}
onChange={(e) => setField('durationSeconds', Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperMinInterval')}</Label>
<Input
type="number"
min={0}
max={1440}
value={bumper.minIntervalMinutes}
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperFont')}</Label>
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
@@ -499,6 +471,16 @@ function BumperSettingsFields({
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperMinInterval')}</Label>
<Input
type="number"
min={0}
max={1440}
value={bumper.minIntervalMinutes}
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperNowLabel')}</Label>
<Input
@@ -515,21 +497,6 @@ function BumperSettingsFields({
onChange={(e) => setField('nextLabel', e.target.value)}
/>
</div>
{colors.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1.5">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<span
className="h-8 w-8 shrink-0 rounded border border-border"
style={{ backgroundColor: cssColor(bumper[key] as string) }}
/>
<Input
value={bumper[key] as string}
onChange={(e) => setField(key, e.target.value as never)}
/>
</div>
</div>
))}
</div>
<label className="flex items-center gap-2 text-sm">
<input
@@ -539,40 +506,259 @@ function BumperSettingsFields({
/>
{t('admin.channels.bumperOnlyDifferent')}
</label>
{showDynamicStyle && (
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
<BumperFileUpload
channelId={channelId}
kind="background"
label={t('admin.channels.bumperBackground')}
hint={t('admin.channels.bumperBackgroundHint')}
has={bumper.hasBackground}
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-matroska"
clear={clearBumperBackground}
upload={uploadBumperBackground}
onSaved={onSaved}
onError={onError}
/>
<BumperFileUpload
channelId={channelId}
kind="music"
label={t('admin.channels.bumperMusic')}
hint={t('admin.channels.bumperMusicHint')}
has={bumper.hasMusic}
accept="audio/*"
clear={clearBumperMusic}
upload={uploadBumperMusic}
onSaved={onSaved}
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
{/* Блоки заставок */}
<div className="flex items-center justify-between border-t border-border pt-4">
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
<Button
size="sm"
variant="outline"
disabled={addTemplate.isPending}
onClick={() => addTemplate.mutate()}
>
{t('admin.channels.bumperAddTemplate')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
<div className="flex flex-col gap-3">
{templates.map((template) => (
<BumperTemplateEditor
key={template.id}
channelId={channel.id}
template={template}
onChanged={onSaved}
onError={onError}
/>
))}
</div>
</CollapsibleCard>
)
}
function BumperTemplateEditor({
channelId,
template,
onChanged,
onError,
}: {
channelId: string
template: BumperTemplateDto
onChanged: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [name, setName] = useState(template.name)
const [colors, setColors] = useState({
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
textColor: template.textColor,
})
useEffect(() => {
setName(template.name)
setColors({
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
textColor: template.textColor,
})
}, [template])
const save = useMutation({
mutationFn: () =>
updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
onSuccess: () => {
toast.success(t('settings.saved'))
onChanged()
},
onError,
})
const remove = useMutation({
mutationFn: () => removeBumperTemplate(channelId, template.id),
onSuccess: onChanged,
onError,
})
const colorFields: { key: keyof typeof colors; label: string }[] = [
{ key: 'backgroundColor', label: t('admin.channels.bumperBg') },
{ key: 'backgroundColor2', label: t('admin.channels.bumperBg2') },
{ key: 'accentColor', label: t('admin.channels.bumperAccent') },
{ key: 'textColor', label: t('admin.channels.bumperText') },
]
return (
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{template.name}</span>
{template.isDefault && <Badge variant="muted">{t('admin.channels.bumperDefault')}</Badge>}
<span className="text-xs text-muted-foreground">
{template.hasAudio && template.audioDurationSeconds != null
? `${Math.round(template.audioDurationSeconds)} ${t('admin.channels.bumperSeconds')}`
: t('admin.channels.bumperDefaultDuration')}
</span>
</div>
)}
{!template.isDefault && (
<Button
size="sm"
variant="destructive"
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('common.delete')}
</Button>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperTemplateName')}</Label>
<Input value={name} maxLength={64} onChange={(e) => setName(e.target.value)} />
</div>
{colorFields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1.5">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<span
className="h-8 w-8 shrink-0 rounded border border-border"
style={{ backgroundColor: cssColor(colors[key]) }}
/>
<Input
value={colors[key]}
onChange={(e) => setColors((c) => ({ ...c, [key]: e.target.value }))}
/>
</div>
</div>
))}
</div>
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
<BumperFileUpload
channelId={channelId}
templateId={template.id}
kind="audio"
label={t('admin.channels.bumperAudio')}
hint={t('admin.channels.bumperAudioHint')}
has={template.hasAudio}
accept="audio/*"
upload={uploadBumperTemplateAudio}
clear={clearBumperTemplateAudio}
onSaved={onChanged}
onError={onError}
/>
<BumperFileUpload
channelId={channelId}
templateId={template.id}
kind="background"
label={t('admin.channels.bumperBackground')}
hint={t('admin.channels.bumperBackgroundHint')}
has={template.hasBackground}
accept="image/*"
upload={uploadBumperTemplateBackground}
clear={clearBumperTemplateBackground}
onSaved={onChanged}
onError={onError}
/>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<BumperPreviewPlayer channelId={channelId} templateId={template.id} onError={onError} />
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
)
}
function BumperPreviewPlayer({
channelId,
templateId,
onError,
}: {
channelId: string
templateId: string
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement>(null)
const [ready, setReady] = useState(false)
const [bust, setBust] = useState(0)
const render = useMutation({
mutationFn: () => renderBumperPreview(channelId, templateId),
onSuccess: () => {
setBust(Date.now())
setReady(true)
},
onError,
})
// Грузим отрендеренный превью-плейлист через hls.js, добавляя Bearer-токен (admin-роут под JWT).
useEffect(() => {
if (!ready) return
const video = videoRef.current
if (!video) return
const src = `${bumperPreviewPlaylistUrl(channelId, templateId)}?t=${bust}`
let hls: Hls | null = null
if (Hls.isSupported()) {
hls = new Hls({
xhrSetup: (xhr) => {
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
},
})
hls.loadSource(src)
hls.attachMedia(video)
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src
}
return () => {
hls?.destroy()
}
}, [ready, bust, channelId, templateId])
return (
<>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={render.isPending}
onClick={() => render.mutate()}
>
{render.isPending
? t('admin.channels.bumperPreviewRendering')
: t('admin.channels.bumperPreview')}
</Button>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperPreviewHint')}
</span>
</div>
{ready && (
<video
ref={videoRef}
controls
playsInline
className="aspect-video w-full max-w-sm rounded-md border border-border bg-black"
/>
)}
</>
)
}
function BumperFileUpload({
channelId,
templateId,
kind,
label,
hint,
@@ -584,26 +770,27 @@ function BumperFileUpload({
onError,
}: {
channelId: string
templateId: string
kind: string
label: string
hint: string
has: boolean
accept: string
upload: (id: string, file: File) => Promise<void>
clear: (id: string) => Promise<void>
upload: (id: string, templateId: string, file: File) => Promise<void>
clear: (id: string, templateId: string) => Promise<void>
onSaved: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const inputId = `bumper-${kind}-${channelId}`
const inputId = `bumper-${kind}-${templateId}`
const uploadMutation = useMutation({
mutationFn: (file: File) => upload(channelId, file),
mutationFn: (file: File) => upload(channelId, templateId, file),
onSuccess: onSaved,
onError,
})
const clearMutation = useMutation({
mutationFn: () => clear(channelId),
mutationFn: () => clear(channelId, templateId),
onSuccess: onSaved,
onError,
})
@@ -832,50 +1019,6 @@ 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 (
<div className="flex flex-wrap items-end gap-2">
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger className="max-w-md">
<SelectValue placeholder={t('admin.channels.pickJingle')} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
{t('common.create')}
</Button>
</div>
)
}
function OverrideForm({
channelId,
options,
+56 -16
View File
@@ -70,23 +70,48 @@ export function removeChannelAd(id: string, channelAdId: string) {
return apiRequest<void>(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' })
}
export function addChannelJingle(id: string, mediaAssetId: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/jingles`, {
export type BumperTemplateStyleBody = {
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
}
export function addBumperTemplate(id: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
method: 'POST',
body: { mediaAssetId },
body: { name },
})
}
export function removeChannelJingle(id: string, channelJingleId: string) {
return apiRequest<void>(`/admin/channels/${id}/jingles/${channelJingleId}`, { method: 'DELETE' })
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'PUT',
body,
})
}
/** Загрузка сырого файла заставки (фон/музыка): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperFile(id: string, kind: 'background' | 'music', file: File): Promise<void> {
export function removeBumperTemplate(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'DELETE',
})
}
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperTemplateFile(
id: string,
templateId: string,
kind: 'audio' | 'background',
file: File,
): Promise<void> {
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()}`)
xhr.open(
'PUT',
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
@@ -108,20 +133,35 @@ function uploadBumperFile(id: string, kind: 'background' | 'music', file: File):
})
}
export function uploadBumperBackground(id: string, file: File) {
return uploadBumperFile(id, 'background', file)
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
export function uploadBumperMusic(id: string, file: File) {
return uploadBumperFile(id, 'music', file)
export function uploadBumperTemplateBackground(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'background', file)
}
export function clearBumperBackground(id: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/background`, { method: 'DELETE' })
export function clearBumperTemplateAudio(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
method: 'DELETE',
})
}
export function clearBumperMusic(id: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/music`, { method: 'DELETE' })
export function clearBumperTemplateBackground(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'DELETE',
})
}
/** Синхронно рендерит пример заставки блока (сервер собирает ffmpeg-клип). */
export function renderBumperPreview(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
method: 'POST',
})
}
export function bumperPreviewPlaylistUrl(id: string, templateId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/index.m3u8`
}
export type OverrideBody = {
+17 -16
View File
@@ -117,22 +117,30 @@ export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes'
export type OverrideMode = 'Exclusive' | 'Boost'
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper'
export type BumperFont = 'Sans' | 'Serif'
export type BumperMode = 'Dynamic' | 'Static' | 'Both'
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst'
/** Общие для канала настройки заставок (стиль/звук — на каждом блоке, см. BumperTemplateDto). */
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
selection: BumperSelection
}
export type BumperTemplateDto = {
id: string
position: number
isDefault: boolean
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
hasBackground: boolean
hasMusic: boolean
hasAudio: boolean
audioDurationSeconds: number | null
}
export type ChannelSummaryDto = {
@@ -160,13 +168,6 @@ 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 = {
@@ -186,10 +187,10 @@ export type ChannelDto = {
adsPerBreak: number
bumpersEnabled: boolean
bumper: BumperSettings
bumperTemplates: BumperTemplateDto[]
fillerAssetId: string | null
shows: ChannelShowDto[]
ads: ChannelAdDto[]
jingles: ChannelJingleDto[]
overrides: ProgrammingOverrideDto[]
}
+41 -29
View File
@@ -183,10 +183,13 @@ const resources = {
betweenBlocks: 'Между блоками',
betweenEpisodes: 'Между сериями',
adsPerBreak: 'Роликов подряд',
bumpersLabel: 'ТВ-заставки на переходах',
bumpers: 'ТВ-заставки',
bumpersLabel: 'Заставки на переходах',
bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу',
bumperStyle: 'Оформление заставки',
bumperDuration: 'Длительность, с',
bumperSelection: 'Выбор блока',
bumperSelectionRotation: 'По кругу',
bumperSelectionRandom: 'Случайно',
bumperSelectionAlwaysFirst: 'Всегда первый',
bumperMinInterval: 'Мин. интервал, мин',
bumperFont: 'Шрифт',
bumperFontSans: 'Гротеск',
@@ -198,22 +201,25 @@ const resources = {
bumperAccent: 'Акцент',
bumperText: 'Текст',
bumperOnlyDifferent: 'Только на смене шоу (не внутри марафона)',
bumperMode: 'Режим заставок',
bumperModeDynamic: 'Динамические «Сейчас/Далее»',
bumperModeStatic: 'Только джинглы',
bumperModeBoth: 'Чередовать',
bumperBackground: 'Фон',
bumperBackgroundHint: 'Картинка или видео-петля; иначе — анимированный градиент',
bumperMusic: 'Музыка',
bumperMusicHint: 'Аудиофайл-подложка; иначе — синтезированный джингл',
bumperTemplates: 'Блоки заставок',
bumperTemplatesHint:
'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.',
bumperAddTemplate: 'Добавить блок',
bumperTemplateName: 'Название',
bumperDefault: 'по умолчанию',
bumperSeconds: 'с',
bumperDefaultDuration: '≈8 с (джингл)',
bumperAudio: 'Звук',
bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл',
bumperPreview: 'Отрендерить пример',
bumperPreviewRendering: 'Рендерим…',
bumperPreviewHint: 'Пример со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
bumperBackground: 'Фон-картинка',
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
bumperFileLoaded: 'загружено',
bumperFileDefault: 'по умолчанию',
bumperUpload: 'Загрузить',
bumperReset: 'Сбросить',
jingles: 'Джинглы-отбивки',
jinglesHint: 'Готовые ролики для статичных заставок; крутятся по кругу на переходах (режимы «Только джинглы» и «Чередовать»).',
pickJingle: 'Выберите ролик',
noJingles: 'Пул джинглов пуст',
filler: 'Заглушка',
noFiller: 'Без заглушки',
shows: 'Шоу канала',
@@ -462,10 +468,13 @@ const resources = {
betweenBlocks: 'Between blocks',
betweenEpisodes: 'Between episodes',
adsPerBreak: 'Ads per break',
bumpers: 'TV bumpers',
bumpersLabel: 'Transition bumpers',
bumpersHint: 'Short “Now / Next” bumper between different shows',
bumperStyle: 'Bumper style',
bumperDuration: 'Duration, s',
bumperSelection: 'Block selection',
bumperSelectionRotation: 'Rotation',
bumperSelectionRandom: 'Random',
bumperSelectionAlwaysFirst: 'Always first',
bumperMinInterval: 'Min interval, min',
bumperFont: 'Font',
bumperFontSans: 'Sans',
@@ -477,22 +486,25 @@ const resources = {
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',
bumperTemplates: 'Bumper blocks',
bumperTemplatesHint:
'Each block has its own sound and style. The first block is the default and cannot be removed. Bumper length follows the sound length.',
bumperAddTemplate: 'Add block',
bumperTemplateName: 'Name',
bumperDefault: 'default',
bumperSeconds: 's',
bumperDefaultDuration: '≈8 s (jingle)',
bumperAudio: 'Sound',
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
bumperPreview: 'Render sample',
bumperPreviewRendering: 'Rendering…',
bumperPreviewHint: 'Sample with sound and animation (example show names). Uses saved settings.',
bumperBackground: 'Background image',
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
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',