Add TV bumpers functionality: introduce configuration options for bumpers in .env.example and appsettings.json, enhance ChannelEndpoints to manage jingles and bumper assets, and update Channel and ScheduleEntry models to support bumper logic. Implement validation for bumper settings and integrate bumper handling in scheduling logic.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelJingle;
|
||||
|
||||
public sealed record AddChannelJingleCommand(Guid ChannelId, Guid MediaAssetId)
|
||||
: ICommand<Result<Guid>>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelJingle;
|
||||
|
||||
public sealed class AddChannelJingleCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<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
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperBackground;
|
||||
|
||||
public sealed record ClearBumperBackgroundCommand(Guid ChannelId) : 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.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
@@ -0,0 +1,7 @@
|
||||
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
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperBackground;
|
||||
|
||||
public sealed class SetBumperBackgroundCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperMusic;
|
||||
|
||||
public sealed record ClearBumperMusicCommand(Guid ChannelId) : 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.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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperMusic;
|
||||
|
||||
public sealed class SetBumperMusicCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<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,22 @@
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Инфраструктурные настройки рендера ТВ-заставок (общие для всех каналов): разрешение, пути к
|
||||
/// шрифтам, версия шаблона. Оформление и правила (цвета, подписи, длительность, интервал) задаются
|
||||
/// на каждом канале — см. <c>Channel.UpdateBumperSettings</c>.
|
||||
/// </summary>
|
||||
public sealed class BumperOptions
|
||||
{
|
||||
public const string SectionName = "Bumpers";
|
||||
|
||||
public int Width { get; init; } = 1280;
|
||||
public int Height { get; init; } = 720;
|
||||
|
||||
/// <summary>Пути к TTF-шрифтам с кириллицей внутри контейнера (см. Dockerfile, fonts-dejavu-core).</summary>
|
||||
public string FontFileSans { get; init; } = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
|
||||
public string FontFileSerif { get; init; } = "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf";
|
||||
|
||||
/// <summary>Версия шаблона рендера — входит в кэш-ключ заставки; меняй при правке ЛОГИКИ рендера
|
||||
/// (не оформления канала), чтобы пересобрать уже отрендеренные заставки.</summary>
|
||||
public int TemplateVersion { get; init; } = 1;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ public sealed record ChannelShowDto(
|
||||
|
||||
public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
|
||||
|
||||
public sealed record ChannelJingleDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
|
||||
|
||||
public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight);
|
||||
|
||||
public sealed record ProgrammingOverrideDto(
|
||||
@@ -27,6 +29,22 @@ public sealed record ProgrammingOverrideDto(
|
||||
IReadOnlyList<OverrideShowDto> Shows
|
||||
);
|
||||
|
||||
public sealed record BumperSettingsDto(
|
||||
BumperMode Mode,
|
||||
int DurationSeconds,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
BumperFont Font,
|
||||
string NowLabel,
|
||||
string NextLabel,
|
||||
int MinIntervalMinutes,
|
||||
bool OnlyBetweenDifferentShows,
|
||||
bool HasBackground,
|
||||
bool HasMusic
|
||||
);
|
||||
|
||||
public sealed record ChannelDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
@@ -34,8 +52,11 @@ public sealed record ChannelDto(
|
||||
bool IsEnabled,
|
||||
AdInsertion AdInsertion,
|
||||
int AdsPerBreak,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsDto Bumper,
|
||||
Guid? FillerAssetId,
|
||||
IReadOnlyList<ChannelShowDto> Shows,
|
||||
IReadOnlyList<ChannelAdDto> Ads,
|
||||
IReadOnlyList<ChannelJingleDto> Jingles,
|
||||
IReadOnlyList<ProgrammingOverrideDto> Overrides
|
||||
);
|
||||
|
||||
@@ -36,11 +36,26 @@ public static class ChannelErrors
|
||||
"Реклама не найдена в пуле канала."
|
||||
);
|
||||
|
||||
public static readonly Error JingleAlreadyAdded = Error.Conflict(
|
||||
"Channels.JingleAlreadyAdded",
|
||||
"Этот ролик уже в пуле джинглов канала."
|
||||
);
|
||||
|
||||
public static readonly Error JingleNotFound = Error.NotFound(
|
||||
"Channels.JingleNotFound",
|
||||
"Джингл не найден в пуле канала."
|
||||
);
|
||||
|
||||
public static readonly Error AssetNotFound = Error.NotFound(
|
||||
"Channels.AssetNotFound",
|
||||
"Медиа-ассет не найден."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidBumperFile = Error.Validation(
|
||||
"Channels.InvalidBumperFile",
|
||||
"Недопустимый файл заставки (формат или размер)."
|
||||
);
|
||||
|
||||
public static readonly Error OverrideNotFound = Error.NotFound(
|
||||
"Channels.OverrideNotFound",
|
||||
"Override не найден."
|
||||
|
||||
@@ -31,9 +31,12 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
|
||||
var adAssetIds = channel.Ads.Select(a => a.MediaAssetId).ToList();
|
||||
var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId)
|
||||
.Concat(channel.Jingles.Select(j => j.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var assetNames = await dbContext.MediaAssets.AsNoTracking()
|
||||
.Where(a => adAssetIds.Contains(a.Id))
|
||||
.Where(a => poolAssetIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, a.OriginalFileName })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||
|
||||
@@ -62,6 +65,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var jingles = channel.Jingles
|
||||
.OrderBy(j => j.Position)
|
||||
.Select(j => new ChannelJingleDto(
|
||||
j.Id,
|
||||
j.MediaAssetId,
|
||||
assetNames.GetValueOrDefault(j.MediaAssetId),
|
||||
j.Position
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var overrides = channel.Overrides
|
||||
.OrderBy(o => o.StartsAtUtc)
|
||||
.Select(o => new ProgrammingOverrideDto(
|
||||
@@ -83,9 +96,26 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
channel.IsEnabled,
|
||||
channel.AdInsertion,
|
||||
channel.AdsPerBreak,
|
||||
channel.BumpersEnabled,
|
||||
new BumperSettingsDto(
|
||||
channel.BumperMode,
|
||||
channel.BumperDurationSeconds,
|
||||
channel.BumperBackgroundColor,
|
||||
channel.BumperBackgroundColor2,
|
||||
channel.BumperAccentColor,
|
||||
channel.BumperTextColor,
|
||||
channel.BumperFont,
|
||||
channel.BumperNowLabel,
|
||||
channel.BumperNextLabel,
|
||||
channel.BumperMinIntervalMinutes,
|
||||
channel.BumperOnlyBetweenDifferentShows,
|
||||
channel.BumperBackgroundExtension is not null,
|
||||
channel.BumperMusicExtension is not null
|
||||
),
|
||||
channel.FillerAssetId,
|
||||
shows,
|
||||
ads,
|
||||
jingles,
|
||||
overrides
|
||||
)
|
||||
);
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelJingle;
|
||||
|
||||
public sealed record RemoveChannelJingleCommand(Guid ChannelId, Guid ChannelJingleId)
|
||||
: ICommand<Result>;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelJingle;
|
||||
|
||||
public sealed class RemoveChannelJingleCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Media;
|
||||
@@ -11,14 +16,23 @@ namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
|
||||
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
|
||||
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
|
||||
/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) и подставляются
|
||||
/// как обычные ассеты.
|
||||
/// </summary>
|
||||
public sealed class ScheduleGenerator(
|
||||
IAppDbContext dbContext,
|
||||
IRandomSource random,
|
||||
IOptions<SchedulerOptions> options
|
||||
IBumperRenderer bumperRenderer,
|
||||
IBumperTemplateStorage bumperStorage,
|
||||
IOptions<SchedulerOptions> options,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions,
|
||||
ILogger<ScheduleGenerator> logger
|
||||
)
|
||||
{
|
||||
private readonly SchedulerOptions _options = options.Value;
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
|
||||
@@ -34,6 +48,7 @@ public sealed class ScheduleGenerator(
|
||||
var channel = await dbContext.Channels
|
||||
.Include(c => c.Shows)
|
||||
.Include(c => c.Ads)
|
||||
.Include(c => c.Jingles)
|
||||
.Include(c => c.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||
@@ -71,22 +86,46 @@ public sealed class ScheduleGenerator(
|
||||
return 0;
|
||||
}
|
||||
|
||||
var showNames = await LoadShowNamesAsync(channel, cancellationToken);
|
||||
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
|
||||
var result = SchedulePlanner.Plan(input, random);
|
||||
|
||||
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана.
|
||||
var bumperAssets = await ResolveBumperAssetsAsync(
|
||||
channel,
|
||||
result.Entries,
|
||||
showNames,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var added = 0;
|
||||
foreach (var entry in result.Entries)
|
||||
{
|
||||
var scheduleEntry = entry.Kind == ScheduleEntryKind.Program
|
||||
? ScheduleEntry.Program(
|
||||
ScheduleEntry? scheduleEntry = entry.Kind switch
|
||||
{
|
||||
ScheduleEntryKind.Program => ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
entry.ShowId!.Value,
|
||||
entry.EpisodeIndex!.Value
|
||||
)
|
||||
: ScheduleEntry.Ad(channel.Id, entry.MediaAssetId, entry.StartsAtUtc, entry.EndsAtUtc);
|
||||
),
|
||||
ScheduleEntryKind.Ad => ScheduleEntry.Ad(
|
||||
channel.Id,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc
|
||||
),
|
||||
ScheduleEntryKind.Bumper => BuildBumperEntry(channel.Id, entry, bumperAssets),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scheduleEntry is null)
|
||||
continue;
|
||||
|
||||
dbContext.ScheduleEntries.Add(scheduleEntry);
|
||||
added++;
|
||||
}
|
||||
|
||||
foreach (var channelShow in channel.Shows)
|
||||
@@ -94,9 +133,233 @@ public sealed class ScheduleGenerator(
|
||||
channelShow.SetNextEpisodeIndex(idx);
|
||||
|
||||
channel.SetNextAdIndex(result.NextAdIndex);
|
||||
channel.SetNextJingleIndex(result.NextJingleIndex);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return result.Entries.Count;
|
||||
return added;
|
||||
}
|
||||
|
||||
private static ScheduleEntry? BuildBumperEntry(
|
||||
Guid channelId,
|
||||
PlannedEntry entry,
|
||||
IReadOnlyDictionary<(Guid, Guid), Guid> bumperAssets
|
||||
)
|
||||
{
|
||||
// Статичный джингл — планировщик уже проставил реальный ассет из пула.
|
||||
if (entry.MediaAssetId != Guid.Empty)
|
||||
return ScheduleEntry.Bumper(
|
||||
channelId,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
entry.ShowId
|
||||
);
|
||||
|
||||
// Динамическая заставка — ассет резолвится по паре шоу (отрендерен/из кэша).
|
||||
if (
|
||||
entry.FromShowId is not { } from
|
||||
|| entry.ToShowId is not { } to
|
||||
|| !bumperAssets.TryGetValue((from, to), out var assetId)
|
||||
)
|
||||
// Заставку не удалось отрендерить — пропускаем запись (слот заполнит филлер/следующая
|
||||
// программа). Планировщик уже учёл её длину, поэтому небольшой зазор допустим.
|
||||
return null;
|
||||
|
||||
return ScheduleEntry.Bumper(channelId, assetId, entry.StartsAtUtc, entry.EndsAtUtc, entry.ToShowId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Для каждой уникальной пары «из→в» из запланированных заставок возвращает id готового
|
||||
/// ассета-заставки: из кэша (<see cref="BumperAsset"/>) либо свежесгенерированного.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<(Guid From, Guid To), 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))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (pairs.Count == 0)
|
||||
return result;
|
||||
|
||||
var fromIds = pairs.Select(p => p.From).Distinct().ToList();
|
||||
var toIds = pairs.Select(p => p.To).Distinct().ToList();
|
||||
|
||||
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
|
||||
var cached = await dbContext.BumperAssets.AsNoTracking()
|
||||
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
||||
.Select(b => new
|
||||
{
|
||||
b.FromShowId,
|
||||
b.ToShowId,
|
||||
b.Signature,
|
||||
b.MediaAssetId,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
|
||||
var readyAssetIds = await dbContext.MediaAssets.AsNoTracking()
|
||||
.Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
|
||||
.Select(a => a.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var readySet = readyAssetIds.ToHashSet();
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var fromName = showNames.GetValueOrDefault(pair.From, "…");
|
||||
var toName = showNames.GetValueOrDefault(pair.To, "…");
|
||||
var signature = ComputeSignature(fromName, toName, styleSignature);
|
||||
|
||||
var hit = cached.FirstOrDefault(c =>
|
||||
c.FromShowId == pair.From
|
||||
&& c.ToShowId == pair.To
|
||||
&& c.Signature == signature
|
||||
&& readySet.Contains(c.MediaAssetId)
|
||||
);
|
||||
if (hit is not null)
|
||||
{
|
||||
result[pair] = hit.MediaAssetId;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var assetId = await RenderBumperAsync(
|
||||
channel,
|
||||
pair.From,
|
||||
pair.To,
|
||||
fromName,
|
||||
toName,
|
||||
signature,
|
||||
cancellationToken
|
||||
);
|
||||
result[pair] = assetId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Не удалось отрендерить заставку {From} → {To}",
|
||||
fromName,
|
||||
toName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Guid> RenderBumperAsync(
|
||||
Channel channel,
|
||||
Guid fromShowId,
|
||||
Guid toShowId,
|
||||
string fromName,
|
||||
string toName,
|
||||
string signature,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
||||
var render = await bumperRenderer.RenderAsync(
|
||||
asset.Id,
|
||||
BuildSpec(channel, fromName, toName),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
asset.MarkReady(
|
||||
render.Duration,
|
||||
render.SegmentSeconds,
|
||||
render.SegmentCount,
|
||||
render.Width,
|
||||
render.Height,
|
||||
"h264",
|
||||
"aac",
|
||||
render.RelativePath
|
||||
);
|
||||
|
||||
dbContext.MediaAssets.Add(asset);
|
||||
dbContext.BumperAssets.Add(
|
||||
BumperAsset.Create(fromShowId, toShowId, signature, asset.Id)
|
||||
);
|
||||
return asset.Id;
|
||||
}
|
||||
|
||||
private BumperRenderSpec BuildSpec(Channel channel, string fromName, string toName) =>
|
||||
new(
|
||||
AlignedBumperDuration(channel),
|
||||
_bumper.Width,
|
||||
_bumper.Height,
|
||||
channel.BumperBackgroundColor,
|
||||
channel.BumperBackgroundColor2,
|
||||
channel.BumperAccentColor,
|
||||
channel.BumperTextColor,
|
||||
FontPath(channel.BumperFont),
|
||||
channel.BumperNowLabel,
|
||||
fromName,
|
||||
channel.BumperNextLabel,
|
||||
toName,
|
||||
bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension),
|
||||
bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension)
|
||||
);
|
||||
|
||||
private string FontPath(BumperFont font) =>
|
||||
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
||||
|
||||
/// <summary>Длительность заставки канала, выровненная вверх до кратности сегменту.</summary>
|
||||
private int AlignedBumperDuration(Channel channel)
|
||||
{
|
||||
var requested = Math.Max(_segmentSeconds, channel.BumperDurationSeconds);
|
||||
return (int)(Math.Ceiling((double)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(string fromName, string toName, string styleSignature)
|
||||
{
|
||||
var raw = string.Join('', fromName, toName, styleSignature);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList();
|
||||
if (showIds.Count == 0)
|
||||
return new Dictionary<Guid, string>();
|
||||
|
||||
return await dbContext.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PlannerInput> BuildInputAsync(
|
||||
@@ -122,6 +385,7 @@ public sealed class ScheduleGenerator(
|
||||
var candidateAssetIds = episodesByShow.Values
|
||||
.SelectMany(x => x)
|
||||
.Concat(channel.Ads.Select(a => a.MediaAssetId))
|
||||
.Concat(channel.Jingles.Select(j => j.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
@@ -162,6 +426,12 @@ public sealed class ScheduleGenerator(
|
||||
.Where(durations.ContainsKey)
|
||||
.ToList();
|
||||
|
||||
var jinglePool = channel.Jingles
|
||||
.OrderBy(j => j.Position)
|
||||
.Select(j => j.MediaAssetId)
|
||||
.Where(durations.ContainsKey)
|
||||
.ToList();
|
||||
|
||||
var overrides = channel.Overrides
|
||||
.Select(o => new PlannerOverride(
|
||||
o.StartsAtUtc,
|
||||
@@ -171,6 +441,15 @@ public sealed class ScheduleGenerator(
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var bumpers = new PlannerBumperConfig(
|
||||
channel.BumpersEnabled,
|
||||
TimeSpan.FromSeconds(AlignedBumperDuration(channel)),
|
||||
channel.BumperOnlyBetweenDifferentShows,
|
||||
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
|
||||
channel.BumperMode,
|
||||
jinglePool
|
||||
);
|
||||
|
||||
return new PlannerInput(
|
||||
channel.Id,
|
||||
channel.AdInsertion,
|
||||
@@ -181,7 +460,9 @@ public sealed class ScheduleGenerator(
|
||||
durations,
|
||||
overrides,
|
||||
startTime,
|
||||
horizonEnd
|
||||
horizonEnd,
|
||||
bumpers,
|
||||
channel.NextJingleIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -10,5 +10,22 @@ public sealed record UpdateChannelSettingsCommand(
|
||||
bool IsEnabled,
|
||||
AdInsertion AdInsertion,
|
||||
int AdsPerBreak,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsInput Bumper,
|
||||
Guid? FillerAssetId
|
||||
) : ICommand<Result>;
|
||||
|
||||
/// <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
|
||||
);
|
||||
|
||||
+14
@@ -32,8 +32,22 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
||||
command.IsEnabled,
|
||||
command.AdInsertion,
|
||||
command.AdsPerBreak,
|
||||
command.BumpersEnabled,
|
||||
command.FillerAssetId
|
||||
);
|
||||
channel.UpdateBumperSettings(
|
||||
command.Bumper.Mode,
|
||||
command.Bumper.DurationSeconds,
|
||||
command.Bumper.BackgroundColor,
|
||||
command.Bumper.BackgroundColor2,
|
||||
command.Bumper.AccentColor,
|
||||
command.Bumper.TextColor,
|
||||
command.Bumper.Font,
|
||||
command.Bumper.NowLabel,
|
||||
command.Bumper.NextLabel,
|
||||
command.Bumper.MinIntervalMinutes,
|
||||
command.Bumper.OnlyBetweenDifferentShows
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -1,13 +1,35 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
public sealed class UpdateChannelSettingsCommandValidator
|
||||
public sealed partial class UpdateChannelSettingsCommandValidator
|
||||
: AbstractValidator<UpdateChannelSettingsCommand>
|
||||
{
|
||||
public UpdateChannelSettingsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10);
|
||||
|
||||
RuleFor(x => x.Bumper.DurationSeconds).InclusiveBetween(2, 30);
|
||||
RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
|
||||
RuleFor(x => x.Bumper.NowLabel).MaximumLength(64);
|
||||
RuleFor(x => x.Bumper.NextLabel).MaximumLength(64);
|
||||
|
||||
// Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат
|
||||
// (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра.
|
||||
RuleFor(x => x.Bumper.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
RuleFor(x => x.Bumper.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
RuleFor(x => x.Bumper.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
RuleFor(x => x.Bumper.TextColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
}
|
||||
|
||||
private const string ColorMessage =
|
||||
"Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white).";
|
||||
|
||||
private static bool BeSafeColor(string? value) =>
|
||||
!string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value);
|
||||
|
||||
[GeneratedRegex(@"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$")]
|
||||
private static partial Regex ColorRegex();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public interface IAppDbContext
|
||||
DbSet<Show> Shows { get; }
|
||||
DbSet<Channel> Channels { get; }
|
||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||
DbSet<BumperAsset> BumperAssets { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Полная спецификация одной заставки для рендера: оформление канала + подписи + названия шоу.
|
||||
/// <see cref="DurationSeconds"/> уже выровнена на длину сегмента (готовит оркестратор), а
|
||||
/// <see cref="FontFile"/> — абсолютный путь к TTF внутри контейнера.
|
||||
/// </summary>
|
||||
public sealed record BumperRenderSpec(
|
||||
int DurationSeconds,
|
||||
int Width,
|
||||
int Height,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
string FontFile,
|
||||
string NowLabel,
|
||||
string NowTitle,
|
||||
string NextLabel,
|
||||
string NextTitle,
|
||||
string? BackgroundFile = null,
|
||||
string? MusicFile = null
|
||||
);
|
||||
|
||||
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
||||
public sealed record BumperRenderResult(
|
||||
TimeSpan Duration,
|
||||
int SegmentSeconds,
|
||||
int SegmentCount,
|
||||
int Width,
|
||||
int Height,
|
||||
string RelativePath
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + текст
|
||||
/// «Сейчас/Далее» + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в
|
||||
/// assets/{assetId} — так же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
|
||||
/// </summary>
|
||||
public interface IBumperRenderer
|
||||
{
|
||||
Task<BumperRenderResult> RenderAsync(
|
||||
Guid assetId,
|
||||
BumperRenderSpec spec,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Хранилище сырых файлов шаблона заставки канала (фон и музыка) под bumpers/{channelId}. В отличие
|
||||
/// от обычных ассетов эти файлы НЕ режутся на HLS — они подаются как входы в рендер заставки.
|
||||
/// </summary>
|
||||
public interface IBumperTemplateStorage
|
||||
{
|
||||
Task SaveBackgroundAsync(
|
||||
Guid channelId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task SaveMusicAsync(
|
||||
Guid channelId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
void DeleteBackground(Guid channelId);
|
||||
void DeleteMusic(Guid channelId);
|
||||
|
||||
/// <summary>Абсолютный путь к загруженному фону или null (нет расширения / файл отсутствует).</summary>
|
||||
string? BackgroundPath(Guid channelId, string? extension);
|
||||
|
||||
/// <summary>Абсолютный путь к загруженной музыке или null.</summary>
|
||||
string? MusicPath(Guid channelId, string? extension);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Media.ListMedia;
|
||||
|
||||
@@ -13,7 +14,9 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var q = dbContext.MediaAssets.AsNoTracking();
|
||||
// Сгенерированные системой ассеты (ТВ-заставки) не показываем в библиотеке медиа.
|
||||
var q = dbContext.MediaAssets.AsNoTracking()
|
||||
.Where(x => x.Source != MediaSource.Generated);
|
||||
|
||||
if (query.Statuses.Count > 0)
|
||||
q = q.Where(x => query.Statuses.Contains(x.Status));
|
||||
|
||||
Reference in New Issue
Block a user