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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user