Add bumper variant management: implement API endpoints for adding, updating, and removing bumper text variants, enhance data models to support variant details, and update scheduling logic to utilize variants. Refactor related components for improved bumper template handling and ensure proper error management for variant operations.

This commit is contained in:
Leonid Pershin
2026-07-25 13:24:11 +03:00
parent f640af1fc4
commit a65bcf4258
34 changed files with 2009 additions and 170 deletions
@@ -1,16 +1,17 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка). На
/// переходе между шоу генератор рендерит «Сейчас/Далее» стилем блока поверх его звука; длительность
/// заставки определяется длиной звука (выравнивается на сегмент при рендере). Общие для канала шрифт,
/// подписи и правила показа живут на <see cref="Channel"/>.
/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка) + набор
/// подблоков (<see cref="Variants"/>) с разным текстом и правилом показа. Длительность заставки — по
/// длине звука (выравнивается на сегмент при рендере). Общий для канала — только шрифт.
///
/// Первый блок (<see cref="Position"/> == 0) — дефолтный, не удаляется; если звук в нём не загружен,
/// рендер синтезирует джингл по умолчанию.
/// </summary>
public class BumperTemplate
{
private readonly List<BumperTextVariant> _variants = new();
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
@@ -46,10 +47,16 @@ public class BumperTemplate
public bool IsDefault => Position == 0;
/// <summary>Подблоки (текст-варианты); порядок — по <see cref="BumperTextVariant.Position"/>.</summary>
public IReadOnlyList<BumperTextVariant> Variants => _variants;
private const string DefaultVariantName = "Текст 1";
private BumperTemplate() { }
internal static BumperTemplate Create(Guid channelId, int position, string name) =>
new()
internal static BumperTemplate Create(Guid channelId, int position, string name)
{
var template = new BumperTemplate
{
Id = Guid.NewGuid(),
ChannelId = channelId,
@@ -65,6 +72,35 @@ public class BumperTemplate
Revision = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
// Дефолтный подблок «Сейчас/Далее», показывается на смене шоу.
template._variants.Add(
BumperTextVariant.Create(template.Id, 0, DefaultVariantName, BumperTrigger.OnShowChange)
);
return template;
}
public BumperTextVariant AddVariant(string name)
{
var nextPosition = _variants.Count == 0 ? 0 : _variants.Max(v => v.Position) + 1;
var variant = BumperTextVariant.Create(Id, nextPosition, name, BumperTrigger.OnShowChange);
_variants.Add(variant);
return variant;
}
public BumperTextVariant? FindVariant(Guid variantId) =>
_variants.FirstOrDefault(v => v.Id == variantId);
/// <summary>Удалить подблок. Последний подблок удалить нельзя (нужен хотя бы один) — вернёт false.</summary>
public bool RemoveVariant(Guid variantId)
{
if (_variants.Count <= 1)
return false;
var variant = _variants.FirstOrDefault(v => v.Id == variantId);
if (variant is null)
return false;
_variants.Remove(variant);
return true;
}
/// <summary>Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</summary>
public void UpdateStyle(
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как формируется текст подблока заставки.</summary>
public enum BumperTextKind
{
/// <summary>«Сейчас/Далее»: две подписи + названия текущего и следующего шоу.</summary>
NowNext,
/// <summary>Произвольные строки (без названий шоу) — например название канала и совет.</summary>
Free,
}
@@ -0,0 +1,82 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Подблок заставки (текст-вариант) внутри <see cref="BumperTemplate"/>. Наследует от блока звук,
/// стиль и фон, но задаёт собственный текст и правило показа (<see cref="Trigger"/>). Позволяет иметь
/// несколько текстов на одной музыке/оформлении, не дублируя блок.
/// </summary>
public class BumperTextVariant
{
public Guid Id { get; private set; }
public Guid BumperTemplateId { get; private set; }
public int Position { get; private set; }
public string Name { get; private set; } = string.Empty;
public BumperTextKind Kind { get; private set; }
// ── Режим NowNext: подписи (названия шоу подставляет генератор) ──
public string NowLabel { get; private set; } = DefaultNowLabel;
public string NextLabel { get; private set; } = DefaultNextLabel;
// ── Режим Free: произвольные строки (например название канала и совет) ──
public string Line1 { get; private set; } = string.Empty;
public string Line2 { get; private set; } = string.Empty;
public BumperTrigger Trigger { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public const string DefaultNowLabel = "СЕЙЧАС";
public const string DefaultNextLabel = "ДАЛЕЕ";
private BumperTextVariant() { }
internal static BumperTextVariant Create(
Guid bumperTemplateId,
int position,
string name,
BumperTrigger trigger
) =>
new()
{
Id = Guid.NewGuid(),
BumperTemplateId = bumperTemplateId,
Position = position,
Name = name,
Kind = BumperTextKind.NowNext,
NowLabel = DefaultNowLabel,
NextLabel = DefaultNextLabel,
Line1 = string.Empty,
Line2 = string.Empty,
Trigger = trigger,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Update(
string name,
BumperTextKind kind,
string nowLabel,
string nextLabel,
string line1,
string line2,
BumperTrigger trigger
)
{
Name = name;
Kind = kind;
NowLabel = nowLabel;
NextLabel = nextLabel;
Line1 = line1;
Line2 = line2;
Trigger = trigger;
}
/// <summary>Подходит ли подблок для перехода: <paramref name="isShowChange"/> — сменилось ли шоу.</summary>
public bool Matches(bool isShowChange) =>
Trigger switch
{
BumperTrigger.OnShowChange => isShowChange,
BumperTrigger.BetweenEpisodes => !isShowChange,
_ => true,
};
}
@@ -0,0 +1,14 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>На каких переходах показывать подблок заставки.</summary>
public enum BumperTrigger
{
/// <summary>Только при смене шоу (следующее шоу отличается от текущего).</summary>
OnShowChange,
/// <summary>Только между блоками одного шоу (шоу не меняется).</summary>
BetweenEpisodes,
/// <summary>И на смене шоу, и между блоками одного шоу.</summary>
Both,
}
@@ -35,17 +35,10 @@ public class Channel
public int NextBumperIndex { get; private set; }
public BumperFont BumperFont { get; private set; }
public string BumperNowLabel { get; private set; } = DefaultNowLabel;
public string BumperNextLabel { get; private set; } = DefaultNextLabel;
/// <summary>Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе).</summary>
public int BumperMinIntervalMinutes { get; private set; }
/// <summary>Ставить заставку только на смене шоу (иначе — и внутри марафона одного шоу).</summary>
public bool BumperOnlyBetweenDifferentShows { get; private set; }
private const string DefaultNowLabel = "СЕЙЧАС";
private const string DefaultNextLabel = "ДАЛЕЕ";
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
@@ -82,10 +75,7 @@ public class Channel
BumperSelection = BumperSelection.Rotation,
NextBumperIndex = 0,
BumperFont = BumperFont.Sans,
BumperNowLabel = DefaultNowLabel,
BumperNextLabel = DefaultNextLabel,
BumperMinIntervalMinutes = 0,
BumperOnlyBetweenDifferentShows = true,
NextAdIndex = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
@@ -111,21 +101,15 @@ public class Channel
FillerAssetId = fillerAssetId;
}
/// <summary>Общие настройки ТВ-заставок канала: шрифт, подписи, правила показа и стратегия выбора блока.</summary>
/// <summary>Общие настройки ТВ-заставок канала: шрифт, мин. интервал и стратегия выбора подблока.</summary>
public void UpdateBumperSettings(
BumperFont font,
string nowLabel,
string nextLabel,
int minIntervalMinutes,
bool onlyBetweenDifferentShows,
BumperSelection selection
)
{
BumperFont = font;
BumperNowLabel = nowLabel;
BumperNextLabel = nextLabel;
BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes);
BumperOnlyBetweenDifferentShows = onlyBetweenDifferentShows;
BumperSelection = selection;
}
@@ -39,12 +39,11 @@ public static class SchedulePlanner
var pick = WeightedPick(candidates, random);
// ТВ-заставка на переходе. Резервируем слот выбранного блока фикс. длины — конкретный
// отрендеренный ассет («Сейчас/Далее» стилем блока поверх его звука) подставит оркестратор.
// ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту)
// резервируем слот выбранного блока — ассет подставит оркестратор.
if (
prevShowId is { } prev
&& input.Bumpers is { Enabled: true } bumper
&& (!bumper.OnlyBetweenDifferentShows || prev != pick.ShowId)
&& (
bumper.MinInterval <= TimeSpan.Zero
|| lastBumperAt is not { } last
@@ -94,9 +93,10 @@ public static class SchedulePlanner
}
/// <summary>
/// Ставит на переходе заставку выбранного блока: резервирует слот его длины и оставляет
/// плейсхолдер с парой шоу + id блока (ассет отрендерит оркестратор). Выбор блока — по стратегии
/// канала (ротация двигает курсор). Возвращает true, если заставка добавлена (курсор сдвинут).
/// Ставит на переходе заставку выбранного подблока: из подходящих по правилу показа (контекст —
/// сменилось ли шоу) выбирает один по стратегии канала и резервирует слот длины его блока. Оставляет
/// плейсхолдер с парой шоу + id блока/варианта (ассет отрендерит оркестратор). Возвращает true, если
/// заставка добавлена (курсор сдвинут).
/// </summary>
private static bool TryPlaceBumper(
List<PlannedEntry> entries,
@@ -108,30 +108,30 @@ public static class SchedulePlanner
ref DateTimeOffset cursor
)
{
var templates = bumper.Templates;
if (templates is not { Count: > 0 })
var isShowChange = fromShowId != toShowId;
var eligible = bumper.Variants
.Where(v => v.Duration > TimeSpan.Zero && MatchesTrigger(v.Trigger, isShowChange))
.ToList();
if (eligible.Count == 0)
return false;
PlannerBumperTemplate template;
PlannerBumperVariant variant;
switch (bumper.Selection)
{
case BumperSelection.Random:
template = templates[random.Next(templates.Count)];
variant = eligible[random.Next(eligible.Count)];
break;
case BumperSelection.AlwaysFirst:
template = templates[0];
variant = eligible[0];
break;
default: // Rotation
var idx = ((nextBumper % templates.Count) + templates.Count) % templates.Count;
template = templates[idx];
var idx = ((nextBumper % eligible.Count) + eligible.Count) % eligible.Count;
variant = eligible[idx];
nextBumper++;
break;
}
if (template.Duration <= TimeSpan.Zero)
return false;
var end = cursor + template.Duration;
var end = cursor + variant.Duration;
entries.Add(
new PlannedEntry(
Guid.Empty,
@@ -142,13 +142,22 @@ public static class SchedulePlanner
null,
fromShowId,
toShowId,
template.TemplateId
variant.TemplateId,
variant.VariantId
)
);
cursor = end;
return true;
}
private static bool MatchesTrigger(BumperTrigger trigger, bool isShowChange) =>
trigger switch
{
BumperTrigger.OnShowChange => isShowChange,
BumperTrigger.BetweenEpisodes => !isShowChange,
_ => true,
};
private static List<(PlannerShow Show, int Weight)> ResolvePolicy(
DateTimeOffset moment,
PlannerInput input,
@@ -22,21 +22,27 @@ public sealed record PlannerOverride(
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
/// <summary>
/// Политика ТВ-заставок на переходах. Планировщик выбирает блок (<see cref="Templates"/>) по
/// стратегии <see cref="Selection"/> и резервирует слот его длины (<see cref="PlannerBumperTemplate.Duration"/>,
/// уже выровнена генератором на сегмент). Конкретный отрендеренный ассет подставляет оркестратор
/// по паре шоу + выбранному блоку.
/// Политика ТВ-заставок на переходах. Планировщик из подходящих подблоков (<see cref="Variants"/>,
/// фильтр по <see cref="PlannerBumperVariant.Trigger"/> и контексту перехода) выбирает один по стратегии
/// <see cref="Selection"/> и резервирует слот длины его блока. Ассет подставляет оркестратор.
/// </summary>
public sealed record PlannerBumperConfig(
bool Enabled,
bool OnlyBetweenDifferentShows,
TimeSpan MinInterval,
BumperSelection Selection,
IReadOnlyList<PlannerBumperTemplate> Templates
IReadOnlyList<PlannerBumperVariant> Variants
);
/// <summary>Блок заставки в терминах планировщика: id + длительность слота (кратна сегменту).</summary>
public sealed record PlannerBumperTemplate(Guid TemplateId, TimeSpan Duration);
/// <summary>
/// Подблок заставки в терминах планировщика: id варианта + id родительского блока (стиль/звук) +
/// длительность слота (кратна сегменту) + правило показа.
/// </summary>
public sealed record PlannerBumperVariant(
Guid VariantId,
Guid TemplateId,
TimeSpan Duration,
BumperTrigger Trigger
);
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
public sealed record PlannerInput(
@@ -69,7 +75,8 @@ public sealed record PlannedEntry(
int? EpisodeIndex,
Guid? FromShowId = null,
Guid? ToShowId = null,
Guid? BumperTemplateId = null
Guid? BumperTemplateId = null,
Guid? BumperVariantId = null
);
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow, рекламы, заставок).</summary>