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