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:
@@ -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>;
|
||||
+32
@@ -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();
|
||||
}
|
||||
}
|
||||
+8
@@ -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>;
|
||||
+32
@@ -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>;
|
||||
+34
@@ -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>;
|
||||
+29
@@ -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();
|
||||
}
|
||||
}
|
||||
+11
@@ -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>;
|
||||
+29
@@ -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>;
|
||||
+35
@@ -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();
|
||||
}
|
||||
}
|
||||
+29
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user