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:
@@ -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>>;
|
||||
-35
@@ -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);
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperBackground;
|
||||
|
||||
public sealed record ClearBumperBackgroundCommand(Guid ChannelId) : ICommand<Result>;
|
||||
-29
@@ -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();
|
||||
}
|
||||
}
|
||||
-7
@@ -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>;
|
||||
-26
@@ -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>;
|
||||
-29
@@ -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>;
|
||||
-26
@@ -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>;
|
||||
+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();
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
|
||||
-7
@@ -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>;
|
||||
-26
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-8
@@ -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
|
||||
);
|
||||
|
||||
+2
-7
@@ -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
-19
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user