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,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);
}