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
@@ -1,14 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Какие заставки вставлять на переходах.</summary>
public enum BumperMode
{
/// <summary>Только динамические «Сейчас/Далее», отрисованные по оформлению канала.</summary>
Dynamic,
/// <summary>Только готовые ролики-джинглы из пула канала (по кругу).</summary>
Static,
/// <summary>И то, и другое — чередуя на соседних переходах.</summary>
Both,
}
@@ -0,0 +1,14 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как выбирать блок заставки на каждом переходе между шоу.</summary>
public enum BumperSelection
{
/// <summary>По кругу в порядке блоков (курсор <see cref="Channel.NextBumperIndex"/>).</summary>
Rotation,
/// <summary>Случайный блок на каждом переходе.</summary>
Random,
/// <summary>Всегда первый (дефолтный) блок.</summary>
AlwaysFirst,
}
@@ -0,0 +1,116 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка). На
/// переходе между шоу генератор рендерит «Сейчас/Далее» стилем блока поверх его звука; длительность
/// заставки определяется длиной звука (выравнивается на сегмент при рендере). Общие для канала шрифт,
/// подписи и правила показа живут на <see cref="Channel"/>.
///
/// Первый блок (<see cref="Position"/> == 0) — дефолтный, не удаляется; если звук в нём не загружен,
/// рендер синтезирует джингл по умолчанию.
/// </summary>
public class BumperTemplate
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
/// <summary>Порядковый номер (0 — дефолтный блок). Используется ротацией и как признак дефолта.</summary>
public int Position { get; private set; }
public string Name { get; private set; } = string.Empty;
// ── Оформление блока (цвета — в нотации ffmpeg: 0xRRGGBB или имя) ──
public string BackgroundColor { get; private set; } = DefaultBackgroundColor;
public string BackgroundColor2 { get; private set; } = DefaultBackgroundColor2;
public string AccentColor { get; private set; } = DefaultAccentColor;
public string TextColor { get; private set; } = DefaultTextColor;
/// <summary>Расширение загруженной фон-картинки (с точкой) или null — тогда фон градиент/постер.</summary>
public string? BackgroundImageExtension { get; private set; }
/// <summary>Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл.</summary>
public string? AudioExtension { get; private set; }
/// <summary>Длина загруженного звука в секундах (замер ffprobe) или null, если звука нет.</summary>
public double? AudioDurationSeconds { get; private set; }
/// <summary>Версия файлов блока (звук/фон). Входит в кэш-ключ рендера — замена файла пересобирает заставки.</summary>
public int Revision { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public const string DefaultBackgroundColor = "0x0b1020";
public const string DefaultBackgroundColor2 = "0x1e293b";
public const string DefaultAccentColor = "0x38bdf8";
public const string DefaultTextColor = "white";
public bool IsDefault => Position == 0;
private BumperTemplate() { }
internal static BumperTemplate Create(Guid channelId, int position, string name) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Position = position,
Name = name,
BackgroundColor = DefaultBackgroundColor,
BackgroundColor2 = DefaultBackgroundColor2,
AccentColor = DefaultAccentColor,
TextColor = DefaultTextColor,
BackgroundImageExtension = null,
AudioExtension = null,
AudioDurationSeconds = null,
Revision = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</summary>
public void UpdateStyle(
string name,
string backgroundColor,
string backgroundColor2,
string accentColor,
string textColor
)
{
Name = name;
BackgroundColor = backgroundColor;
BackgroundColor2 = backgroundColor2;
AccentColor = accentColor;
TextColor = textColor;
}
/// <summary>Отметить загруженный звук (extension — с точкой, нижний регистр) и его длину. Меняет ревизию.</summary>
public void SetAudio(string extension, double durationSeconds)
{
AudioExtension = extension;
AudioDurationSeconds = durationSeconds > 0 ? durationSeconds : null;
Revision++;
}
public void ClearAudio()
{
if (AudioExtension is null && AudioDurationSeconds is null)
return;
AudioExtension = null;
AudioDurationSeconds = null;
Revision++;
}
/// <summary>Отметить загруженную фон-картинку (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
public void SetBackgroundImage(string extension)
{
BackgroundImageExtension = extension;
Revision++;
}
public void ClearBackgroundImage()
{
if (BackgroundImageExtension is null)
return;
BackgroundImageExtension = null;
Revision++;
}
}
@@ -10,7 +10,7 @@ public class Channel
private readonly List<ChannelShow> _shows = new();
private readonly List<ChannelAd> _ads = new();
private readonly List<ProgrammingOverride> _overrides = new();
private readonly List<ChannelJingle> _jingles = new();
private readonly List<BumperTemplate> _bumperTemplates = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
@@ -26,28 +26,14 @@ public class Channel
/// <summary>Вставлять ли ТВ-заставки на переходах между разными шоу.</summary>
public bool BumpersEnabled { get; private set; }
/// <summary>Какие заставки вставлять: динамические «Сейчас/Далее», статичные джинглы или оба.</summary>
public BumperMode BumperMode { get; private set; }
// ── Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. BumperTemplates) ──
/// <summary>Расширение загруженного фона (с точкой) или null — тогда синтезируется градиент.</summary>
public string? BumperBackgroundExtension { get; private set; }
/// <summary>Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; }
/// <summary>Расширение загруженной музыки (с точкой) или null — тогда синтезируется джингл.</summary>
public string? BumperMusicExtension { get; private set; }
/// <summary>Курсор ротации блоков заставок.</summary>
public int NextBumperIndex { get; private set; }
/// <summary>Счётчик версии файлов заставки (фон/музыка). Входит в кэш-ключ, чтобы замена файла тем
/// же именем пересобирала уже отрендеренные динамические заставки.</summary>
public int BumperRevision { get; private set; }
/// <summary>Курсор ротации пула джинглов.</summary>
public int NextJingleIndex { get; private set; }
// ── Оформление и правила ТВ-заставок (значения на канал; см. UpdateBumperSettings) ──
public int BumperDurationSeconds { get; private set; }
public string BumperBackgroundColor { get; private set; } = DefaultBackgroundColor;
public string BumperBackgroundColor2 { get; private set; } = DefaultBackgroundColor2;
public string BumperAccentColor { get; private set; } = DefaultAccentColor;
public string BumperTextColor { get; private set; } = DefaultTextColor;
public BumperFont BumperFont { get; private set; }
public string BumperNowLabel { get; private set; } = DefaultNowLabel;
public string BumperNextLabel { get; private set; } = DefaultNextLabel;
@@ -58,13 +44,9 @@ public class Channel
/// <summary>Ставить заставку только на смене шоу (иначе — и внутри марафона одного шоу).</summary>
public bool BumperOnlyBetweenDifferentShows { get; private set; }
private const int DefaultBumperDurationSeconds = 8;
private const string DefaultBackgroundColor = "0x0b1020";
private const string DefaultBackgroundColor2 = "0x1e293b";
private const string DefaultAccentColor = "0x38bdf8";
private const string DefaultTextColor = "white";
private const string DefaultNowLabel = "СЕЙЧАС";
private const string DefaultNextLabel = "ДАЛЕЕ";
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
@@ -80,13 +62,14 @@ public class Channel
public IReadOnlyList<ChannelAd> Ads => _ads;
public IReadOnlyList<ProgrammingOverride> Overrides => _overrides;
/// <summary>Пул джинглов-отбивок; порядок ротации — по <see cref="ChannelJingle.Position"/>.</summary>
public IReadOnlyList<ChannelJingle> Jingles => _jingles;
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
private Channel() { }
public static Channel Create(string name, string slug, DateTimeOffset epochUtc) =>
new()
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
{
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = name,
@@ -96,16 +79,8 @@ public class Channel
AdInsertion = AdInsertion.BetweenBlocks,
AdsPerBreak = 1,
BumpersEnabled = false,
BumperMode = BumperMode.Dynamic,
BumperBackgroundExtension = null,
BumperMusicExtension = null,
BumperRevision = 0,
NextJingleIndex = 0,
BumperDurationSeconds = DefaultBumperDurationSeconds,
BumperBackgroundColor = DefaultBackgroundColor,
BumperBackgroundColor2 = DefaultBackgroundColor2,
BumperAccentColor = DefaultAccentColor,
BumperTextColor = DefaultTextColor,
BumperSelection = BumperSelection.Rotation,
NextBumperIndex = 0,
BumperFont = BumperFont.Sans,
BumperNowLabel = DefaultNowLabel,
BumperNextLabel = DefaultNextLabel,
@@ -114,6 +89,10 @@ public class Channel
NextAdIndex = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
return channel;
}
public void UpdateSettings(
string name,
@@ -132,85 +111,48 @@ public class Channel
FillerAssetId = fillerAssetId;
}
/// <summary>Оформление и правила ТВ-заставок канала. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</summary>
/// <summary>Общие настройки ТВ-заставок канала: шрифт, подписи, правила показа и стратегия выбора блока.</summary>
public void UpdateBumperSettings(
BumperMode mode,
int durationSeconds,
string backgroundColor,
string backgroundColor2,
string accentColor,
string textColor,
BumperFont font,
string nowLabel,
string nextLabel,
int minIntervalMinutes,
bool onlyBetweenDifferentShows
bool onlyBetweenDifferentShows,
BumperSelection selection
)
{
BumperMode = mode;
BumperDurationSeconds = durationSeconds;
BumperBackgroundColor = backgroundColor;
BumperBackgroundColor2 = backgroundColor2;
BumperAccentColor = accentColor;
BumperTextColor = textColor;
BumperFont = font;
BumperNowLabel = nowLabel;
BumperNextLabel = nextLabel;
BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes);
BumperOnlyBetweenDifferentShows = onlyBetweenDifferentShows;
BumperSelection = selection;
}
/// <summary>Отметить загруженный фон (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
public void SetBumperBackground(string extension)
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
public BumperTemplate AddBumperTemplate(string name)
{
BumperBackgroundExtension = extension;
BumperRevision++;
var nextPosition = _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
var template = BumperTemplate.Create(Id, nextPosition, name);
_bumperTemplates.Add(template);
return template;
}
public void ClearBumperBackground()
{
if (BumperBackgroundExtension is null)
return;
BumperBackgroundExtension = null;
BumperRevision++;
}
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
/// <summary>Отметить загруженную музыку (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
public void SetBumperMusic(string extension)
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
public bool RemoveBumperTemplate(Guid templateId)
{
BumperMusicExtension = extension;
BumperRevision++;
}
public void ClearBumperMusic()
{
if (BumperMusicExtension is null)
return;
BumperMusicExtension = null;
BumperRevision++;
}
public ChannelJingle AddJingle(Guid mediaAssetId)
{
var nextPosition = _jingles.Count == 0 ? 0 : _jingles.Max(j => j.Position) + 1;
var jingle = ChannelJingle.Create(Id, mediaAssetId, nextPosition);
_jingles.Add(jingle);
return jingle;
}
public bool RemoveJingle(Guid channelJingleId)
{
var jingle = _jingles.FirstOrDefault(j => j.Id == channelJingleId);
if (jingle is null)
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
if (template is null || template.IsDefault)
return false;
_jingles.Remove(jingle);
_bumperTemplates.Remove(template);
return true;
}
public bool HasJingle(Guid mediaAssetId) => _jingles.Any(j => j.MediaAssetId == mediaAssetId);
/// <summary>Планировщик двигает курсор пула джинглов по мере вставки отбивок.</summary>
public void SetNextJingleIndex(int index) => NextJingleIndex = index;
/// <summary>Планировщик двигает курсор ротации блоков заставок по мере вставки.</summary>
public void SetNextBumperIndex(int index) => NextBumperIndex = index;
public ChannelShow? FindShow(Guid channelShowId) => _shows.FirstOrDefault(s => s.Id == channelShowId);
@@ -1,22 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Готовый ролик-джингл (отбивка) в пуле канала. Крутятся по кругу в порядке <see cref="Position"/>
/// на переходах между шоу, когда режим заставок — Static или Both.</summary>
public class ChannelJingle
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public int Position { get; private set; }
private ChannelJingle() { }
internal static ChannelJingle Create(Guid channelId, Guid mediaAssetId, int position) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Position = position,
};
}
@@ -19,18 +19,17 @@ 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 nextBumper = input.NextBumperIndex;
// Есть ли вообще из чего строить эфир.
var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0);
if (!anyPlayable)
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
var cursor = input.StartTime;
var iterations = 0;
Guid? prevShowId = null;
DateTimeOffset? lastBumperAt = null;
var bumperCount = 0;
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
{
@@ -40,8 +39,8 @@ public static class SchedulePlanner
var pick = WeightedPick(candidates, random);
// ТВ-заставка на переходе. Динамическую (Сейчас/Далее) резервируем слотом фикс. длины —
// ассет отрендерит оркестратор; статичный джингл берём готовым из пула (реальная длина).
// ТВ-заставка на переходе. Резервируем слот выбранного блока фикс. длины — конкретный
// отрендеренный ассет («Сейчас/Далее» стилем блока поверх его звука) подставит оркестратор.
if (
prevShowId is { } prev
&& input.Bumpers is { Enabled: true } bumper
@@ -54,11 +53,8 @@ public static class SchedulePlanner
)
{
var bumperStart = cursor;
if (TryPlaceBumper(entries, bumper, bumperCount, prev, pick.ShowId, input, ref nextJingle, ref cursor))
{
if (TryPlaceBumper(entries, bumper, prev, pick.ShowId, random, ref nextBumper, ref cursor))
lastBumperAt = bumperStart;
bumperCount++;
}
}
var blockStart = cursor;
@@ -94,75 +90,62 @@ public static class SchedulePlanner
prevShowId = pick.ShowId;
}
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
}
/// <summary>
/// Ставит одну заставку на переходе по режиму канала. Динамическая — плейсхолдер фикс. длины
/// (ассет подставит оркестратор). Статичная — готовый джингл из пула (реальная длина, курсор
/// двигается). В режиме Both типы чередуются; при пустом пуле Both уходит в динамику.
/// Возвращает true, если заставка добавлена (курсор сдвинут).
/// Ставит на переходе заставку выбранного блока: резервирует слот его длины и оставляет
/// плейсхолдер с парой шоу + id блока (ассет отрендерит оркестратор). Выбор блока — по стратегии
/// канала (ротация двигает курсор). Возвращает true, если заставка добавлена (курсор сдвинут).
/// </summary>
private static bool TryPlaceBumper(
List<PlannedEntry> entries,
PlannerBumperConfig bumper,
int bumperCount,
Guid fromShowId,
Guid toShowId,
PlannerInput input,
ref int nextJingle,
IRandomSource random,
ref int nextBumper,
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)
var templates = bumper.Templates;
if (templates is not { Count: > 0 })
return false;
var dynEnd = cursor + bumper.Duration;
PlannerBumperTemplate template;
switch (bumper.Selection)
{
case BumperSelection.Random:
template = templates[random.Next(templates.Count)];
break;
case BumperSelection.AlwaysFirst:
template = templates[0];
break;
default: // Rotation
var idx = ((nextBumper % templates.Count) + templates.Count) % templates.Count;
template = templates[idx];
nextBumper++;
break;
}
if (template.Duration <= TimeSpan.Zero)
return false;
var end = cursor + template.Duration;
entries.Add(
new PlannedEntry(
Guid.Empty,
ScheduleEntryKind.Bumper,
cursor,
dynEnd,
end,
toShowId,
null,
fromShowId,
toShowId
toShowId,
template.TemplateId
)
);
cursor = dynEnd;
cursor = end;
return true;
}
@@ -22,19 +22,22 @@ public sealed record PlannerOverride(
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
/// <summary>
/// Политика ТВ-заставок на переходах. <see cref="Duration"/> должна быть кратна длине сегмента
/// (генератор выравнивает). Планировщик резервирует под заставку слот этой длины, а конкретный
/// сгенерированный ассет подставляет уже оркестратор.
/// Политика ТВ-заставок на переходах. Планировщик выбирает блок (<see cref="Templates"/>) по
/// стратегии <see cref="Selection"/> и резервирует слот его длины (<see cref="PlannerBumperTemplate.Duration"/>,
/// уже выровнена генератором на сегмент). Конкретный отрендеренный ассет подставляет оркестратор
/// по паре шоу + выбранному блоку.
/// </summary>
public sealed record PlannerBumperConfig(
bool Enabled,
TimeSpan Duration,
bool OnlyBetweenDifferentShows,
TimeSpan MinInterval,
BumperMode Mode = BumperMode.Dynamic,
IReadOnlyList<Guid>? JinglePool = null
BumperSelection Selection,
IReadOnlyList<PlannerBumperTemplate> Templates
);
/// <summary>Блок заставки в терминах планировщика: id + длительность слота (кратна сегменту).</summary>
public sealed record PlannerBumperTemplate(Guid TemplateId, TimeSpan Duration);
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
public sealed record PlannerInput(
Guid ChannelId,
@@ -48,13 +51,14 @@ public sealed record PlannerInput(
DateTimeOffset StartTime,
DateTimeOffset HorizonEnd,
PlannerBumperConfig? Bumpers = null,
int NextJingleIndex = 0
int NextBumperIndex = 0
);
/// <summary>
/// Одна запланированная запись (ещё не доменная сущность). Для заставок (<see cref="Kind"/> ==
/// <see cref="ScheduleEntryKind.Bumper"/>) <see cref="MediaAssetId"/> пуст — его подставит
/// оркестратор после рендера по паре (<see cref="FromShowId"/> → <see cref="ToShowId"/>).
/// оркестратор после рендера по паре (<see cref="FromShowId"/> → <see cref="ToShowId"/>) и выбранному
/// блоку (<see cref="BumperTemplateId"/>).
/// </summary>
public sealed record PlannedEntry(
Guid MediaAssetId,
@@ -64,13 +68,14 @@ public sealed record PlannedEntry(
Guid? ShowId,
int? EpisodeIndex,
Guid? FromShowId = null,
Guid? ToShowId = null
Guid? ToShowId = null,
Guid? BumperTemplateId = null
);
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы).</summary>
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow, рекламы, заставок).</summary>
public sealed record PlannerResult(
IReadOnlyList<PlannedEntry> Entries,
IReadOnlyDictionary<Guid, int> NextEpisodeIndexByChannelShow,
int NextAdIndex,
int NextJingleIndex
int NextBumperIndex
);