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