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:
Leonid Pershin
2026-07-25 00:45:22 +03:00
parent 622bf1e440
commit 0cfc72166a
60 changed files with 5073 additions and 33 deletions
@@ -19,14 +19,18 @@ public static class SchedulePlanner
var byShowId = input.Shows.ToDictionary(s => s.ShowId);
var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex);
var nextAd = input.NextAdIndex;
var nextJingle = input.NextJingleIndex;
// Есть ли вообще из чего строить эфир.
var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0);
if (!anyPlayable)
return new PlannerResult(entries, nextEpisode, nextAd);
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
var cursor = input.StartTime;
var iterations = 0;
Guid? prevShowId = null;
DateTimeOffset? lastBumperAt = null;
var bumperCount = 0;
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
{
@@ -35,6 +39,28 @@ public static class SchedulePlanner
break;
var pick = WeightedPick(candidates, random);
// ТВ-заставка на переходе. Динамическую (Сейчас/Далее) резервируем слотом фикс. длины —
// ассет отрендерит оркестратор; статичный джингл берём готовым из пула (реальная длина).
if (
prevShowId is { } prev
&& input.Bumpers is { Enabled: true } bumper
&& (!bumper.OnlyBetweenDifferentShows || prev != pick.ShowId)
&& (
bumper.MinInterval <= TimeSpan.Zero
|| lastBumperAt is not { } last
|| cursor - last >= bumper.MinInterval
)
)
{
var bumperStart = cursor;
if (TryPlaceBumper(entries, bumper, bumperCount, prev, pick.ShowId, input, ref nextJingle, ref cursor))
{
lastBumperAt = bumperStart;
bumperCount++;
}
}
var blockStart = cursor;
var block = CollectBlock(pick, nextEpisode, input, cursor);
@@ -64,9 +90,80 @@ public static class SchedulePlanner
// Защита от зацикливания, если длительности нулевые/отсутствуют — эфир не сдвинулся.
if (cursor <= blockStart)
break;
prevShowId = pick.ShowId;
}
return new PlannerResult(entries, nextEpisode, nextAd);
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
}
/// <summary>
/// Ставит одну заставку на переходе по режиму канала. Динамическая — плейсхолдер фикс. длины
/// (ассет подставит оркестратор). Статичная — готовый джингл из пула (реальная длина, курсор
/// двигается). В режиме Both типы чередуются; при пустом пуле Both уходит в динамику.
/// Возвращает true, если заставка добавлена (курсор сдвинут).
/// </summary>
private static bool TryPlaceBumper(
List<PlannedEntry> entries,
PlannerBumperConfig bumper,
int bumperCount,
Guid fromShowId,
Guid toShowId,
PlannerInput input,
ref int nextJingle,
ref DateTimeOffset cursor
)
{
var pool = bumper.JinglePool;
var hasPool = pool is { Count: > 0 };
var wantStatic =
bumper.Mode == BumperMode.Static
|| (bumper.Mode == BumperMode.Both && bumperCount % 2 == 1);
// В режиме Both при пустом пуле показываем динамику.
if (wantStatic && !hasPool && bumper.Mode == BumperMode.Both)
wantStatic = false;
if (wantStatic)
{
if (!hasPool)
return false; // Static без пула — вставлять нечего.
var idx = ((nextJingle % pool!.Count) + pool.Count) % pool.Count;
var assetId = pool[idx];
nextJingle++;
var dur = DurationOf(assetId, input);
if (dur <= TimeSpan.Zero)
return false;
var end = cursor + dur;
entries.Add(
new PlannedEntry(assetId, ScheduleEntryKind.Bumper, cursor, end, null, null)
);
cursor = end;
return true;
}
// Динамическая заставка «Сейчас/Далее» — плейсхолдер с парой шоу для рендера.
if (bumper.Duration <= TimeSpan.Zero)
return false;
var dynEnd = cursor + bumper.Duration;
entries.Add(
new PlannedEntry(
Guid.Empty,
ScheduleEntryKind.Bumper,
cursor,
dynEnd,
toShowId,
null,
fromShowId,
toShowId
)
);
cursor = dynEnd;
return true;
}
private static List<(PlannerShow Show, int Weight)> ResolvePolicy(
@@ -21,6 +21,20 @@ public sealed record PlannerOverride(
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
/// <summary>
/// Политика ТВ-заставок на переходах. <see cref="Duration"/> должна быть кратна длине сегмента
/// (генератор выравнивает). Планировщик резервирует под заставку слот этой длины, а конкретный
/// сгенерированный ассет подставляет уже оркестратор.
/// </summary>
public sealed record PlannerBumperConfig(
bool Enabled,
TimeSpan Duration,
bool OnlyBetweenDifferentShows,
TimeSpan MinInterval,
BumperMode Mode = BumperMode.Dynamic,
IReadOnlyList<Guid>? JinglePool = null
);
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
public sealed record PlannerInput(
Guid ChannelId,
@@ -32,22 +46,31 @@ public sealed record PlannerInput(
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyList<PlannerOverride> Overrides,
DateTimeOffset StartTime,
DateTimeOffset HorizonEnd
DateTimeOffset HorizonEnd,
PlannerBumperConfig? Bumpers = null,
int NextJingleIndex = 0
);
/// <summary>Одна запланированная запись (ещё не доменная сущность).</summary>
/// <summary>
/// Одна запланированная запись (ещё не доменная сущность). Для заставок (<see cref="Kind"/> ==
/// <see cref="ScheduleEntryKind.Bumper"/>) <see cref="MediaAssetId"/> пуст — его подставит
/// оркестратор после рендера по паре (<see cref="FromShowId"/> → <see cref="ToShowId"/>).
/// </summary>
public sealed record PlannedEntry(
Guid MediaAssetId,
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
int? EpisodeIndex
int? EpisodeIndex,
Guid? FromShowId = null,
Guid? ToShowId = null
);
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы).</summary>
public sealed record PlannerResult(
IReadOnlyList<PlannedEntry> Entries,
IReadOnlyDictionary<Guid, int> NextEpisodeIndexByChannelShow,
int NextAdIndex
int NextAdIndex,
int NextJingleIndex
);