Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -1,11 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Политика вставки рекламы на канале.</summary>
public enum AdInsertion
{
/// <summary>Реклама после целого блока серий.</summary>
BetweenBlocks,
/// <summary>Реклама после каждой серии.</summary>
BetweenEpisodes,
}
@@ -1,11 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как измеряется блок серий одного шоу за один выбор ротации.</summary>
public enum BlockMode
{
/// <summary>Ровно N серий подряд.</summary>
Count,
/// <summary>Набор серий подряд, пока не наберётся ~M минут (последняя входит целиком).</summary>
Duration,
}
@@ -1,15 +1,12 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Канал линейного эфира: базовая взвешенная ротация шоу (<see cref="Shows"/>), пул рекламы
/// (<see cref="Ads"/>), временные override'ы (<see cref="Overrides"/>) и политика вставки рекламы.
/// Планировщик разворачивает всё это в расписание встык на несколько дней вперёд.
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
/// филлер и общие настройки заставок.
/// </summary>
public class Channel
{
private readonly List<ChannelShow> _shows = new();
private readonly List<ChannelAd> _ads = new();
private readonly List<ProgrammingOverride> _overrides = new();
private readonly List<BumperTemplate> _bumperTemplates = new();
public Guid Id { get; private set; }
@@ -20,14 +17,37 @@ public class Channel
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { get; private set; }
public AdInsertion AdInsertion { get; private set; }
public int AdsPerBreak { get; private set; }
/// <summary>
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
/// включается глобальным флагом настроек сайта; null — номер не задан.
/// </summary>
public int? Number { get; private set; }
/// <summary>
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
/// </summary>
public int UtcOffsetMinutes { get; private set; }
/// <summary>
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
/// </summary>
public TimeOnly DayStartTime { get; private set; }
/// <summary>Активный шаблон сетки канала (один на канал).</summary>
public Guid? TemplateId { get; private set; }
public const int DefaultUtcOffsetMinutes = 180;
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
// ── Настройки ТВ-заставок. В срезе 2 условия показа переезжают в элементы стыка,
// здесь останется только общий для канала шрифт. ──
/// <summary>Вставлять ли ТВ-заставки на переходах между разными шоу.</summary>
public bool BumpersEnabled { get; private set; }
// ── Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. BumperTemplates) ──
/// <summary>Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; }
@@ -50,17 +70,8 @@ public class Channel
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
/// <summary>Курсор ротации рекламного пула.</summary>
public int NextAdIndex { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyList<ChannelShow> Shows => _shows;
/// <summary>Пул рекламы (backing-field для EF); порядок ротации — по <see cref="ChannelAd.Position"/>.</summary>
public IReadOnlyList<ChannelAd> Ads => _ads;
public IReadOnlyList<ProgrammingOverride> Overrides => _overrides;
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
@@ -75,8 +86,6 @@ public class Channel
Slug = slug,
IsEnabled = true,
EpochUtc = epochUtc,
AdInsertion = AdInsertion.BetweenBlocks,
AdsPerBreak = 1,
BumpersEnabled = false,
BumperSelection = BumperSelection.Rotation,
NextBumperIndex = 0,
@@ -84,7 +93,8 @@ public class Channel
BumperMinIntervalMinutes = 0,
BumperShowChangeChance = 1.0,
BumperEpisodeChangeChance = 1.0,
NextAdIndex = 0,
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
DayStartTime = DefaultDayStartTime,
CreatedAt = DateTimeOffset.UtcNow,
};
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
@@ -95,16 +105,12 @@ public class Channel
public void UpdateSettings(
string name,
bool isEnabled,
AdInsertion adInsertion,
int adsPerBreak,
bool bumpersEnabled,
Guid? fillerAssetId
)
{
Name = name;
IsEnabled = isEnabled;
AdInsertion = adInsertion;
AdsPerBreak = adsPerBreak;
BumpersEnabled = bumpersEnabled;
FillerAssetId = fillerAssetId;
}
@@ -154,79 +160,17 @@ public class Channel
/// <summary>Планировщик двигает курсор ротации блоков заставок по мере вставки.</summary>
public void SetNextBumperIndex(int index) => NextBumperIndex = index;
public ChannelShow? FindShow(Guid channelShowId) =>
_shows.FirstOrDefault(s => s.Id == channelShowId);
/// <summary>Привязать активный шаблон сетки.</summary>
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
public ChannelShow AddShow(Guid showId, int weight, BlockMode blockMode, int blockValue)
/// <summary>
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
/// </summary>
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
{
var channelShow = ChannelShow.Create(Id, showId, weight, blockMode, blockValue);
_shows.Add(channelShow);
return channelShow;
Number = number is > 0 ? number : null;
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
DayStartTime = dayStartTime;
}
public bool RemoveShow(Guid channelShowId)
{
var channelShow = _shows.FirstOrDefault(s => s.Id == channelShowId);
if (channelShow is null)
return false;
_shows.Remove(channelShow);
return true;
}
public bool HasShow(Guid showId) => _shows.Any(s => s.ShowId == showId);
public ChannelAd AddAd(Guid mediaAssetId)
{
var nextPosition = _ads.Count == 0 ? 0 : _ads.Max(a => a.Position) + 1;
var ad = ChannelAd.Create(Id, mediaAssetId, nextPosition);
_ads.Add(ad);
return ad;
}
public bool RemoveAd(Guid channelAdId)
{
var ad = _ads.FirstOrDefault(a => a.Id == channelAdId);
if (ad is null)
return false;
_ads.Remove(ad);
return true;
}
public bool HasAd(Guid mediaAssetId) => _ads.Any(a => a.MediaAssetId == mediaAssetId);
public ProgrammingOverride AddOverride(
OverrideMode mode,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
)
{
var ovr = ProgrammingOverride.CreateOneTime(Id, mode, startsAtUtc, endsAtUtc);
_overrides.Add(ovr);
return ovr;
}
/// <summary>Еженедельный override: день недели (0=Вс..6=Сб) + окно минут суток (UTC).</summary>
public ProgrammingOverride AddWeeklyOverride(
OverrideMode mode,
int dayOfWeek,
int startMinute,
int endMinute
)
{
var ovr = ProgrammingOverride.CreateWeekly(Id, mode, dayOfWeek, startMinute, endMinute);
_overrides.Add(ovr);
return ovr;
}
public bool RemoveOverride(Guid overrideId)
{
var ovr = _overrides.FirstOrDefault(o => o.Id == overrideId);
if (ovr is null)
return false;
_overrides.Remove(ovr);
return true;
}
/// <summary>Планировщик двигает курсор рекламы по мере вставки врезок.</summary>
public void SetNextAdIndex(int index) => NextAdIndex = index;
}
@@ -1,21 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Рекламный ассет в пуле канала. Врезки крутятся по кругу в порядке <see cref="Position"/>.</summary>
public class ChannelAd
{
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 ChannelAd() { }
internal static ChannelAd Create(Guid channelId, Guid mediaAssetId, int position) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Position = position,
};
}
@@ -1,87 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Связка канал↔шоу: вес в случайной ротации, режим и размер блока, а также персональный для этого
/// канала курсор серий (<see cref="NextEpisodeIndex"/>) — индекс следующей серии в упорядоченном
/// списке шоу.
/// </summary>
public class ChannelShow
{
private readonly List<ChannelShowHour> _preferredHours = new();
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid ShowId { get; private set; }
public int Weight { get; private set; }
public BlockMode BlockMode { get; private set; }
/// <summary>Число серий (<see cref="BlockMode.Count"/>) или минут (<see cref="BlockMode.Duration"/>).</summary>
public int BlockValue { get; private set; }
public bool IsEnabled { get; private set; }
/// <summary>Индекс следующей серии для этого канала (0-based в упорядоченном списке серий шоу).</summary>
public int NextEpisodeIndex { get; private set; }
/// <summary>Во сколько раз усиливать вес шоу в предпочтительные часы (1 — без буста).</summary>
public int PreferredWeightMultiplier { get; private set; } = DefaultPreferredWeightMultiplier;
/// <summary>Окна предпочтительных часов (пусто — шоу без предпочтений, вес не меняется).</summary>
public IReadOnlyList<ChannelShowHour> PreferredHours => _preferredHours;
public const int DefaultPreferredWeightMultiplier = 3;
private ChannelShow() { }
internal static ChannelShow Create(
Guid channelId,
Guid showId,
int weight,
BlockMode blockMode,
int blockValue
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
ShowId = showId,
Weight = weight,
BlockMode = blockMode,
BlockValue = blockValue,
IsEnabled = true,
NextEpisodeIndex = 0,
PreferredWeightMultiplier = DefaultPreferredWeightMultiplier,
};
public void Update(int weight, BlockMode blockMode, int blockValue, bool isEnabled)
{
Weight = weight;
BlockMode = blockMode;
BlockValue = blockValue;
IsEnabled = isEnabled;
}
/// <summary>
/// Задаёт множитель веса и полностью заменяет набор окон предпочтительных часов. Окна нормализуются:
/// отбрасываются некорректные ([0,24], start &lt; end), совпадающие схлопываются.
/// </summary>
public void SetPreferredHours(int multiplier, IEnumerable<(int StartHour, int EndHour)> windows)
{
PreferredWeightMultiplier = Math.Max(1, multiplier);
_preferredHours.Clear();
foreach (
var (start, end) in windows
.Where(w => w.StartHour >= 0 && w.EndHour <= 24 && w.StartHour < w.EndHour)
.Distinct()
)
{
_preferredHours.Add(ChannelShowHour.Create(Id, start, end));
}
}
/// <summary>Час суток (0..23) попадает в одно из окон предпочтительных часов.</summary>
public bool IsPreferredAt(int hour) => _preferredHours.Any(w => w.Contains(hour));
/// <summary>Планировщик двигает курсор по мере постановки серий в расписание.</summary>
public void SetNextEpisodeIndex(int index) => NextEpisodeIndex = index;
}
@@ -1,32 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Окно предпочтительных часов для шоу на канале: полуинтервал часов суток [StartHour, EndHour)
/// в UTC. В эти часы вес шоу в ротации умножается на <see cref="ChannelShow.PreferredWeightMultiplier"/>.
/// Ночные окна задаются двумя записями (напр. 22–24 и 0–2), заворот через полночь не поддерживается.
/// </summary>
public class ChannelShowHour
{
public Guid Id { get; private set; }
public Guid ChannelShowId { get; private set; }
/// <summary>Начало окна — час суток (0..23).</summary>
public int StartHour { get; private set; }
/// <summary>Конец окна (исключительно) — час суток (1..24).</summary>
public int EndHour { get; private set; }
private ChannelShowHour() { }
internal static ChannelShowHour Create(Guid channelShowId, int startHour, int endHour) =>
new()
{
Id = Guid.NewGuid(),
ChannelShowId = channelShowId,
StartHour = startHour,
EndHour = endHour,
};
/// <summary>Попадает ли час суток (0..23) в это окно.</summary>
public bool Contains(int hour) => hour >= StartHour && hour < EndHour;
}
@@ -1,11 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Режим временного override (марафон / кампания) поверх базовой ротации.</summary>
public enum OverrideMode
{
/// <summary>В окне играет только одно шоу (марафон).</summary>
Exclusive,
/// <summary>В окне действуют подменённые веса перечисленных шоу (остальные не участвуют).</summary>
Boost,
}
@@ -1,11 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как повторяется override программирования канала.</summary>
public enum OverrideRecurrence
{
/// <summary>Разовое окно [StartsAtUtc, EndsAtUtc).</summary>
OneTime,
/// <summary>Еженедельно в заданный день недели на окне часов суток (UTC).</summary>
Weekly,
}
@@ -1,21 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Шоу внутри override с его подменённым весом (для Boost) или единственное шоу (для Exclusive).</summary>
public class OverrideShow
{
public Guid Id { get; private set; }
public Guid ProgrammingOverrideId { get; private set; }
public Guid ShowId { get; private set; }
public int Weight { get; private set; }
private OverrideShow() { }
internal static OverrideShow Create(Guid overrideId, Guid showId, int weight) =>
new()
{
Id = Guid.NewGuid(),
ProgrammingOverrideId = overrideId,
ShowId = showId,
Weight = weight,
};
}
@@ -1,108 +0,0 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Временный override программирования канала. Разовый (<see cref="OverrideRecurrence.OneTime"/>) —
/// на окне [<see cref="StartsAtUtc"/>, <see cref="EndsAtUtc"/>). Еженедельный
/// (<see cref="OverrideRecurrence.Weekly"/>) — каждую неделю в <see cref="DayOfWeek"/> на окне минут
/// суток [<see cref="StartMinute"/>, <see cref="EndMinute"/>) в UTC. Марафон = обычно
/// <see cref="OverrideMode.Exclusive"/> с одним шоу; пересекающийся с генерируемым временем override
/// заменяет базовую ротацию.
/// </summary>
public class ProgrammingOverride
{
private readonly List<OverrideShow> _shows = new();
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public OverrideMode Mode { get; private set; }
public OverrideRecurrence Recurrence { get; private set; }
// ── OneTime ──
public DateTimeOffset? StartsAtUtc { get; private set; }
public DateTimeOffset? EndsAtUtc { get; private set; }
// ── Weekly ── (день недели 0=Вс..6=Сб как System.DayOfWeek/JS getDay; минуты суток 0..1440, UTC)
public int? DayOfWeek { get; private set; }
public int? StartMinute { get; private set; }
public int? EndMinute { get; private set; }
public IReadOnlyList<OverrideShow> Shows => _shows;
private ProgrammingOverride() { }
internal static ProgrammingOverride CreateOneTime(
Guid channelId,
OverrideMode mode,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Mode = mode,
Recurrence = OverrideRecurrence.OneTime,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
internal static ProgrammingOverride CreateWeekly(
Guid channelId,
OverrideMode mode,
int dayOfWeek,
int startMinute,
int endMinute
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Mode = mode,
Recurrence = OverrideRecurrence.Weekly,
DayOfWeek = dayOfWeek,
StartMinute = startMinute,
EndMinute = endMinute,
};
public OverrideShow AddShow(Guid showId, int weight)
{
var entry = OverrideShow.Create(Id, showId, weight);
_shows.Add(entry);
return entry;
}
/// <summary>Действует ли override в этот момент (по типу повторения).</summary>
public bool Covers(DateTimeOffset moment)
{
if (Recurrence == OverrideRecurrence.Weekly)
{
if (
DayOfWeek is not { } day
|| StartMinute is not { } start
|| EndMinute is not { } end
)
return false;
var utc = moment.UtcDateTime;
var minuteOfDay = utc.Hour * 60 + utc.Minute;
var today = (int)utc.DayOfWeek;
if (end > start)
// Обычное окно в пределах одних суток [start, end).
return today == day && minuteOfDay >= start && minuteOfDay < end;
if (end < start)
{
// Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день.
var nextDay = (day + 1) % 7;
return (today == day && minuteOfDay >= start)
|| (today == nextDay && minuteOfDay < end);
}
return false; // end == start — пустое окно
}
return moment >= StartsAtUtc && moment < EndsAtUtc;
}
}
@@ -22,8 +22,44 @@ public class ScheduleEntry
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — старая ротация либо служебная запись).</summary>
public Guid? SlotId { get; private set; }
/// <summary>
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
/// в угадывание.
/// </summary>
public string? TraceJson { get; private set; }
private ScheduleEntry() { }
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
public static ScheduleEntry FromSlot(
Guid channelId,
Guid mediaAssetId,
ScheduleEntryKind kind,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
int? episodeIndex,
Guid? slotId,
string? traceJson
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
SlotId = slotId,
TraceJson = traceJson,
};
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
@@ -1,6 +1,6 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Тип записи расписания.</summary>
/// <summary>Тип записи расписания. Одна лента на всё: EPG — это фильтр по <see cref="Program"/>.</summary>
public enum ScheduleEntryKind
{
/// <summary>Программа (серия шоу).</summary>
@@ -11,4 +11,10 @@ public enum ScheduleEntryKind
/// <summary>ТВ-заставка на переходе между шоу («Сейчас: X · Далее: Y»).</summary>
Bumper,
/// <summary>Заполнитель: место, не покрытое слотами либо не добранное контентом.</summary>
Fallback,
/// <summary>Конец вещания — настроечная таблица вместо эфира.</summary>
SignOff,
}
@@ -1,352 +0,0 @@
namespace TeleWave.Domain.Broadcast.Scheduling;
/// <summary>
/// Чистая эфирная математика: разворачивает конфигурацию канала в последовательность записей встык
/// от <see cref="PlannerInput.StartTime"/> до <see cref="PlannerInput.HorizonEnd"/>. Без БД, ФС и
/// ffmpeg — полностью юнит-тестируемо (см. SchedulePlannerTests).
///
/// Инварианты: серии одного шоу идут по порядку (курсор <see cref="PlannerShow.NextEpisodeIndex"/>),
/// на конце сериала — заворот на первую серию; выбор шоу — взвешенно-случайный; override на окне
/// заменяет базовую ротацию; реклама вставляется по политике канала.
/// </summary>
public static class SchedulePlanner
{
private const int IterationBackstop = 1_000_000;
public static PlannerResult Plan(PlannerInput input, IRandomSource random)
{
var entries = new List<PlannedEntry>();
var byShowId = input.Shows.ToDictionary(s => s.ShowId);
var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex);
var nextAd = input.NextAdIndex;
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, nextBumper);
var cursor = input.StartTime;
var iterations = 0;
Guid? prevShowId = null;
DateTimeOffset? lastBumperAt = null;
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
{
var candidates = ResolvePolicy(cursor, input, byShowId);
if (candidates.Count == 0)
break;
var pick = WeightedPick(candidates, random);
// ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту)
// резервируем слот выбранного блока — ассет подставит оркестратор. Само появление
// ограничено мин. интервалом и вероятностью для типа перехода (смена шоу / между блоками).
if (
prevShowId is { } prev
&& input.Bumpers is { Enabled: true } bumper
&& (
bumper.MinInterval <= TimeSpan.Zero
|| lastBumperAt is not { } last
|| cursor - last >= bumper.MinInterval
)
)
{
var isShowChange = prev != pick.ShowId;
var chance = isShowChange ? bumper.ShowChangeChance : bumper.EpisodeChangeChance;
if (RollChance(chance, random))
{
var bumperStart = cursor;
if (
TryPlaceBumper(
entries,
bumper,
prev,
pick.ShowId,
random,
ref nextBumper,
ref cursor
)
)
lastBumperAt = bumperStart;
}
}
var blockStart = cursor;
var block = CollectBlock(pick, nextEpisode, input, cursor);
foreach (var episode in block)
{
var duration = DurationOf(episode.AssetId, input);
var end = cursor + duration;
entries.Add(
new PlannedEntry(
episode.AssetId,
ScheduleEntryKind.Program,
cursor,
end,
pick.ShowId,
episode.Index
)
);
cursor = end;
if (input.AdInsertion == AdInsertion.BetweenEpisodes)
cursor = InsertAds(entries, input, cursor, ref nextAd);
}
if (input.AdInsertion == AdInsertion.BetweenBlocks)
cursor = InsertAds(entries, input, cursor, ref nextAd);
// Защита от зацикливания, если длительности нулевые/отсутствуют — эфир не сдвинулся.
if (cursor <= blockStart)
break;
prevShowId = pick.ShowId;
}
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
}
/// <summary>
/// Ставит на переходе заставку выбранного подблока: из подходящих по правилу показа (контекст —
/// сменилось ли шоу) выбирает один по стратегии канала и резервирует слот длины его блока. Оставляет
/// плейсхолдер с парой шоу + id блока/варианта (ассет отрендерит оркестратор). Возвращает true, если
/// заставка добавлена (курсор сдвинут).
/// </summary>
private static bool TryPlaceBumper(
List<PlannedEntry> entries,
PlannerBumperConfig bumper,
Guid fromShowId,
Guid toShowId,
IRandomSource random,
ref int nextBumper,
ref DateTimeOffset cursor
)
{
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;
PlannerBumperVariant variant;
switch (bumper.Selection)
{
case BumperSelection.Random:
variant = eligible[random.Next(eligible.Count)];
break;
case BumperSelection.WeightedRandom:
variant = WeightedPickVariant(eligible, random);
break;
case BumperSelection.AlwaysFirst:
variant = eligible[0];
break;
default: // Rotation
var idx = ((nextBumper % eligible.Count) + eligible.Count) % eligible.Count;
variant = eligible[idx];
nextBumper++;
break;
}
var end = cursor + variant.Duration;
entries.Add(
new PlannedEntry(
Guid.Empty,
ScheduleEntryKind.Bumper,
cursor,
end,
toShowId,
null,
fromShowId,
toShowId,
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,
};
/// <summary>Прошла ли проверка вероятности появления (chance 0..1). 1 — всегда, 0 — никогда.</summary>
private static bool RollChance(double chance, IRandomSource random)
{
if (chance >= 1.0)
return true;
if (chance <= 0.0)
return false;
return random.Next(10000) < (int)Math.Round(chance * 10000);
}
/// <summary>Взвешенный случайный выбор подблока по <see cref="PlannerBumperVariant.Weight"/> (нулевые веса → равновероятно).</summary>
private static PlannerBumperVariant WeightedPickVariant(
List<PlannerBumperVariant> eligible,
IRandomSource random
)
{
var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
if (total <= 0)
return eligible[random.Next(eligible.Count)];
var roll = random.Next((int)Math.Min(total, int.MaxValue));
long acc = 0;
foreach (var v in eligible)
{
acc += Math.Max(0, v.Weight);
if (roll < acc)
return v;
}
return eligible[^1];
}
private static List<(PlannerShow Show, int Weight)> ResolvePolicy(
DateTimeOffset moment,
PlannerInput input,
IReadOnlyDictionary<Guid, PlannerShow> byShowId
)
{
var ovr = input.Overrides.FirstOrDefault(o => o.Covers(moment));
if (ovr is not null)
{
var overridden = new List<(PlannerShow, int)>();
foreach (var os in ovr.Shows)
{
if (
!byShowId.TryGetValue(os.ShowId, out var show)
|| show.EpisodeAssetIds.Count == 0
)
continue;
var weight = ovr.Mode == OverrideMode.Exclusive ? 1 : os.Weight;
if (weight > 0)
overridden.Add((show, weight));
}
if (overridden.Count > 0)
return overridden;
// Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации.
}
var hour = moment.UtcDateTime.Hour;
return input
.Shows.Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0)
.Select(s => (s, EffectiveWeight(s, hour)))
.ToList();
}
/// <summary>Вес шоу с учётом предпочтительных часов: в окне — усиливается множителем, иначе базовый.</summary>
private static int EffectiveWeight(PlannerShow show, int hour)
{
if (
show.PreferredWeightMultiplier > 1
&& show.PreferredHours is { Count: > 0 } windows
&& windows.Any(w => w.Contains(hour))
)
// long-умножение + clamp: экстремальные вес/множитель не переполняют int
// (иначе отрицательный total молча вырождает взвешенный выбор в первого кандидата).
return (int)Math.Min((long)show.Weight * show.PreferredWeightMultiplier, int.MaxValue);
return show.Weight;
}
private static PlannerShow WeightedPick(
List<(PlannerShow Show, int Weight)> candidates,
IRandomSource random
)
{
var total = candidates.Sum(c => (long)c.Weight);
if (total <= 0)
return candidates[0].Show;
var roll = random.Next((int)Math.Min(total, int.MaxValue));
long acc = 0;
foreach (var (show, weight) in candidates)
{
acc += weight;
if (roll < acc)
return show;
}
return candidates[^1].Show;
}
private static List<(Guid AssetId, int Index)> CollectBlock(
PlannerShow show,
Dictionary<Guid, int> nextEpisode,
PlannerInput input,
DateTimeOffset cursor
)
{
var result = new List<(Guid, int)>();
var count = show.EpisodeAssetIds.Count;
var idx = ((nextEpisode[show.ChannelShowId] % count) + count) % count;
if (show.BlockMode == BlockMode.Count)
{
var n = Math.Max(1, show.BlockValue);
for (var i = 0; i < n; i++)
{
result.Add((show.EpisodeAssetIds[idx], idx));
idx = (idx + 1) % count;
}
}
else
{
var budget = TimeSpan.FromMinutes(Math.Max(1, show.BlockValue));
var accumulated = TimeSpan.Zero;
var guard = 0;
do
{
var assetId = show.EpisodeAssetIds[idx];
result.Add((assetId, idx));
accumulated += DurationOf(assetId, input);
idx = (idx + 1) % count;
guard++;
} while (
accumulated < budget
&& cursor + accumulated < input.HorizonEnd
&& guard < IterationBackstop
);
}
nextEpisode[show.ChannelShowId] = idx;
return result;
}
private static DateTimeOffset InsertAds(
List<PlannedEntry> entries,
PlannerInput input,
DateTimeOffset cursor,
ref int nextAd
)
{
if (input.AdPool.Count == 0 || input.AdsPerBreak <= 0)
return cursor;
for (var i = 0; i < input.AdsPerBreak; i++)
{
var assetId = input.AdPool[
((nextAd % input.AdPool.Count) + input.AdPool.Count) % input.AdPool.Count
];
nextAd++;
var end = cursor + DurationOf(assetId, input);
entries.Add(new PlannedEntry(assetId, ScheduleEntryKind.Ad, cursor, end, null, null));
cursor = end;
}
return cursor;
}
private static TimeSpan DurationOf(Guid assetId, PlannerInput input) =>
input.Durations.TryGetValue(assetId, out var duration) ? duration : TimeSpan.Zero;
}
@@ -1,143 +0,0 @@
namespace TeleWave.Domain.Broadcast.Scheduling;
/// <summary>Шоу канала, подготовленное для планировщика: только готовые серии, с курсором.</summary>
public sealed record PlannerShow(
Guid ChannelShowId,
Guid ShowId,
int Weight,
BlockMode BlockMode,
int BlockValue,
IReadOnlyList<Guid> EpisodeAssetIds,
int NextEpisodeIndex,
IReadOnlyList<PlannerHourWindow>? PreferredHours = null,
int PreferredWeightMultiplier = 1
);
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC) для планировщика.</summary>
public sealed record PlannerHourWindow(int StartHour, int EndHour)
{
public bool Contains(int hour) => hour >= StartHour && hour < EndHour;
}
/// <summary>
/// Override в терминах планировщика: режим + шоу с весами + правило действия (разовое окно либо
/// еженедельно по дню недели на окне минут суток UTC).
/// </summary>
public sealed record PlannerOverride(
OverrideMode Mode,
IReadOnlyList<PlannerOverrideShow> Shows,
OverrideRecurrence Recurrence = OverrideRecurrence.OneTime,
DateTimeOffset? StartsAtUtc = null,
DateTimeOffset? EndsAtUtc = null,
int? DayOfWeek = null,
int? StartMinute = null,
int? EndMinute = null
)
{
/// <summary>Действует ли override в этот момент.</summary>
public bool Covers(DateTimeOffset moment)
{
if (Recurrence == OverrideRecurrence.Weekly)
{
if (
DayOfWeek is not { } day
|| StartMinute is not { } start
|| EndMinute is not { } end
)
return false;
var utc = moment.UtcDateTime;
var minuteOfDay = utc.Hour * 60 + utc.Minute;
var today = (int)utc.DayOfWeek;
if (end > start)
// Обычное окно в пределах одних суток [start, end).
return today == day && minuteOfDay >= start && minuteOfDay < end;
if (end < start)
{
// Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день.
var nextDay = (day + 1) % 7;
return (today == day && minuteOfDay >= start)
|| (today == nextDay && minuteOfDay < end);
}
return false; // end == start — пустое окно
}
return moment >= StartsAtUtc && moment < EndsAtUtc;
}
}
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
/// <summary>
/// Политика ТВ-заставок на переходах. Планировщик из подходящих подблоков (<see cref="Variants"/>,
/// фильтр по <see cref="PlannerBumperVariant.Trigger"/> и контексту перехода) выбирает один по стратегии
/// <see cref="Selection"/> и резервирует слот длины его блока. Ассет подставляет оркестратор.
/// <see cref="ShowChangeChance"/>/<see cref="EpisodeChangeChance"/> — вероятность самого появления
/// заставки на смене шоу / между блоками одного шоу (0..1).
/// </summary>
public sealed record PlannerBumperConfig(
bool Enabled,
TimeSpan MinInterval,
BumperSelection Selection,
IReadOnlyList<PlannerBumperVariant> Variants,
double ShowChangeChance = 1.0,
double EpisodeChangeChance = 1.0
);
/// <summary>
/// Подблок заставки в терминах планировщика: id варианта + id родительского блока (стиль/звук) +
/// длительность слота (кратна сегменту) + правило показа + вес (для <see cref="BumperSelection.WeightedRandom"/>).
/// </summary>
public sealed record PlannerBumperVariant(
Guid VariantId,
Guid TemplateId,
TimeSpan Duration,
BumperTrigger Trigger,
int Weight = 1
);
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
public sealed record PlannerInput(
Guid ChannelId,
AdInsertion AdInsertion,
int AdsPerBreak,
int NextAdIndex,
IReadOnlyList<PlannerShow> Shows,
IReadOnlyList<Guid> AdPool,
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyList<PlannerOverride> Overrides,
DateTimeOffset StartTime,
DateTimeOffset HorizonEnd,
PlannerBumperConfig? Bumpers = null,
int NextBumperIndex = 0
);
/// <summary>
/// Одна запланированная запись (ещё не доменная сущность). Для заставок (<see cref="Kind"/> ==
/// <see cref="ScheduleEntryKind.Bumper"/>) <see cref="MediaAssetId"/> пуст — его подставит
/// оркестратор после рендера по паре (<see cref="FromShowId"/> → <see cref="ToShowId"/>) и выбранному
/// блоку (<see cref="BumperTemplateId"/>).
/// </summary>
public sealed record PlannedEntry(
Guid MediaAssetId,
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
int? EpisodeIndex,
Guid? FromShowId = null,
Guid? ToShowId = null,
Guid? BumperTemplateId = null,
Guid? BumperVariantId = null
);
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow, рекламы, заставок).</summary>
public sealed record PlannerResult(
IReadOnlyList<PlannedEntry> Entries,
IReadOnlyDictionary<Guid, int> NextEpisodeIndexByChannelShow,
int NextAdIndex,
int NextBumperIndex
);
@@ -0,0 +1,89 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Франшиза: упорядоченный набор шоу, который играется как одно целое. «Терминатор» — это три
/// полнометражки в порядке 1→2→3.
///
/// Живёт в библиотеке, а не в планировщике, потому что «одно произведение из трёх частей» — факт
/// о контенте, а не о канале. Планировщику коллекция видна как обычная последовательность
/// воспроизводимых единиц, ровно как сериал со своими сериями: за счёт этого «три части подряд
/// в один вечер» и «три дня по одному фильму» получаются одним и тем же слотом с разным размером
/// блока, без спецлогики для коллекций.
/// </summary>
public class Collection
{
private readonly List<CollectionItem> _items = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string? Description { get; private set; }
/// <summary>Постер коллекции — ссылка на запись общего реестра изображений или null.</summary>
public Guid? PosterImageId { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Позиции коллекции (backing-field для EF); порядок показа — по <see cref="CollectionItem.Position"/>.</summary>
public IReadOnlyList<CollectionItem> Items => _items;
private Collection() { }
public static Collection Create(string name, string? description = null) =>
new()
{
Id = Guid.NewGuid(),
Name = name.Trim(),
Description = description,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Rename(string name, string? description)
{
Name = name.Trim();
Description = description;
}
/// <summary>Привязать/снять постер (ссылка на реестр изображений; сама картинка остаётся в галерее).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
public bool HasShow(Guid showId) => _items.Any(i => i.ShowId == showId);
/// <summary>Добавляет шоу в конец. Повторное добавление игнорируется — возвращает null.</summary>
public CollectionItem? AddShow(Guid showId)
{
if (HasShow(showId))
return null;
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
var item = CollectionItem.Create(Id, showId, nextPosition);
_items.Add(item);
return item;
}
public bool RemoveShow(Guid showId)
{
var item = _items.FirstOrDefault(i => i.ShowId == showId);
if (item is null)
return false;
_items.Remove(item);
return true;
}
/// <summary>
/// Переставляет позиции в порядке переданных идентификаторов. Не упомянутые остаются после них,
/// сохраняя относительный порядок, а неизвестные игнорируются — так частичный ввод (перетащили
/// один элемент) не теряет остальные.
/// </summary>
public void Reorder(IEnumerable<Guid> showIdsInOrder)
{
var requested = showIdsInOrder.Distinct().Where(HasShow).ToList();
var rest = _items
.Where(i => !requested.Contains(i.ShowId))
.OrderBy(i => i.Position)
.Select(i => i.ShowId);
var position = 0;
foreach (var showId in requested.Concat(rest))
_items.First(i => i.ShowId == showId).SetPosition(position++);
}
}
@@ -0,0 +1,25 @@
namespace TeleWave.Domain.Library;
/// <summary>Шоу в составе коллекции. Порядок показа — по <see cref="Position"/>.</summary>
public class CollectionItem
{
public Guid Id { get; private set; }
public Guid CollectionId { get; private set; }
public Guid ShowId { get; private set; }
/// <summary>Порядковый номер внутри коллекции (может иметь разрывы после удалений).</summary>
public int Position { get; private set; }
private CollectionItem() { }
internal static CollectionItem Create(Guid collectionId, Guid showId, int position) =>
new()
{
Id = Guid.NewGuid(),
CollectionId = collectionId,
ShowId = showId,
Position = position,
};
internal void SetPosition(int position) => Position = position;
}
@@ -0,0 +1,70 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Жанр из общего справочника. Справочник, а не свободные строки: по нему собираются группы контента
/// для планировщика («один случайный боевик по пятницам»), а жанры от внешних провайдеров
/// (TMDb/OMDb) приводятся к нему через <see cref="Aliases"/>.
///
/// Свои произвольные метки («ретро», «новогоднее») сюда не заводятся — их роль выполняют группы
/// с ручным составом.
/// </summary>
public class Genre
{
private readonly List<GenreAlias> _aliases = new();
public Guid Id { get; private set; }
/// <summary>Отображаемое название («Боевик»).</summary>
public string Name { get; private set; } = string.Empty;
/// <summary>Стабильный ключ («action») — по нему жанр находится при повторном сидинге.</summary>
public string Slug { get; private set; } = string.Empty;
/// <summary>Порядок в списках UI (меньше — выше).</summary>
public int SortOrder { get; private set; }
/// <summary>Признак жанра из стартового сида — такой нельзя удалить, только переименовать.</summary>
public bool IsSystem { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Варианты написания для сопоставления с внешними источниками (backing-field для EF).</summary>
public IReadOnlyList<GenreAlias> Aliases => _aliases;
private Genre() { }
public static Genre Create(string name, string slug, int sortOrder = 0, bool isSystem = false) =>
new()
{
Id = Guid.NewGuid(),
Name = name.Trim(),
Slug = GenreAlias.Normalize(slug),
SortOrder = sortOrder,
IsSystem = isSystem,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Rename(string name) => Name = name.Trim();
public void SetSortOrder(int sortOrder) => SortOrder = sortOrder;
/// <summary>Добавляет вариант написания, если такого ещё нет. Пустые значения игнорируются.</summary>
public void AddAlias(string alias)
{
var normalized = GenreAlias.Normalize(alias);
if (normalized.Length == 0 || _aliases.Any(a => a.Value == normalized))
return;
_aliases.Add(GenreAlias.Create(Id, normalized));
}
/// <summary>
/// Полностью заменяет набор вариантов написания. Снятые псевдонимы из сида вернутся при следующем
/// старте приложения — сид дописывает недостающие, но только если их не занял другой жанр.
/// </summary>
public void ReplaceAliases(IEnumerable<string> aliases)
{
_aliases.Clear();
foreach (var alias in aliases)
AddAlias(alias);
}
}
@@ -0,0 +1,46 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Вариант написания жанра для сопоставления с внешними источниками. Хранит уже нормализованное
/// значение (см. <see cref="Normalize"/>), поэтому поиск — это точное сравнение, без LIKE и без
/// приведения регистра на стороне БД.
///
/// Формы значения: числовой идентификатор провайдера (<c>tmdb:28</c>) либо имя жанра на любом языке
/// (<c>action</c>, <c>боевик</c>, <c>sci-fi</c>). Один жанр справочника собирает под собой сколько
/// угодно вариантов — так «Action» и «Action &amp; Adventure» из TMDb сводятся к одному «Боевику».
/// </summary>
public class GenreAlias
{
public Guid Id { get; private set; }
public Guid GenreId { get; private set; }
/// <summary>Нормализованное значение — уникально по всему справочнику.</summary>
public string Value { get; private set; } = string.Empty;
private GenreAlias() { }
internal static GenreAlias Create(Guid genreId, string value) =>
new()
{
Id = Guid.NewGuid(),
GenreId = genreId,
Value = Normalize(value),
};
/// <summary>Приводит написание к каноничному виду: нижний регистр, схлопнутые пробелы, без краёв.</summary>
public static string Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var trimmed = value.Trim().ToLowerInvariant();
return string.Join(
' ',
trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
);
}
/// <summary>Псевдоним по идентификатору жанра во внешнем источнике (<c>tmdb:28</c>).</summary>
public static string ProviderKey(string provider, string externalId) =>
Normalize($"{provider}:{externalId}");
}
+25 -1
View File
@@ -8,6 +8,7 @@ namespace TeleWave.Domain.Library;
public class Show
{
private readonly List<ShowEpisode> _episodes = new();
private readonly List<ShowGenre> _genres = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
@@ -40,6 +41,9 @@ public class Show
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
@@ -60,9 +64,29 @@ public class Show
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать категорию аудитории (обычное/детское/взрослое).</summary>
/// <summary>Задать категорию аудитории.</summary>
public void SetAudience(ShowAudience audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
/// <summary>
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
/// так шоу с жанрами никогда не остаётся без основного.
/// </summary>
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
{
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
_genres.Clear();
if (ids.Count == 0)
return;
var primary = primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
foreach (var id in ids)
_genres.Add(ShowGenre.Create(Id, id, id == primary));
}
public void Rename(string name, string? description)
{
Name = name;
@@ -1,14 +1,27 @@
namespace TeleWave.Domain.Library;
/// <summary>Категория аудитории шоу (пригодится для фильтров/разграничения показа).</summary>
/// <summary>
/// Возрастная категория шоу. Значения упорядочены по возрастанию строгости — на этом порядке строятся
/// правила планировщика вида «до 23:00 не строже подросткового». Новые категории можно вставлять
/// только с сохранением монотонности и с миграцией данных.
///
/// Это не гейт для зрителя: что показывает канал, то и смотрится. Категория нужна планировщику,
/// чтобы не поставить взрослое в детский эфир.
/// </summary>
public enum ShowAudience
{
/// <summary>Обычное — без ограничений (по умолчанию).</summary>
General,
/// <summary>Детское.</summary>
Kids,
Kids = 0,
/// <summary>Семейное.</summary>
Family = 1,
/// <summary>Подростковое.</summary>
Teen = 2,
/// <summary>Общее — без ограничений (по умолчанию).</summary>
General = 3,
/// <summary>Взрослое.</summary>
Adult,
Adult = 4,
}
@@ -0,0 +1,23 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Жанр, проставленный шоу. Ровно один из жанров шоу — основной (<see cref="IsPrimary"/>): он
/// показывается в списках, остальные участвуют в фильтрах наравне с ним. Инвариант «основной ровно
/// один» держит агрегат <see cref="Show.SetGenres"/>.
/// </summary>
public class ShowGenre
{
public Guid ShowId { get; private set; }
public Guid GenreId { get; private set; }
public bool IsPrimary { get; private set; }
private ShowGenre() { }
internal static ShowGenre Create(Guid showId, Guid genreId, bool isPrimary) =>
new()
{
ShowId = showId,
GenreId = genreId,
IsPrimary = isPrimary,
};
}
@@ -4,8 +4,18 @@ namespace TeleWave.Domain.Library;
public enum ShowKind
{
/// <summary>Сериал: упорядоченный список серий, идут по порядку.</summary>
Series,
Series = 0,
/// <summary>Разовый выпуск/полнометражка: ровно одна «серия».</summary>
Single,
Single = 1,
/// <summary>
/// Ролик-врезка: реклама, промо, джингл. Технически то же шоу с одной «серией», поэтому попадает
/// в группы и коллекции наравне с контентом — так рекламный блок собирается обычной коллекцией,
/// а остывание («не крутить один ролик дважды подряд») достаётся ему бесплатно.
///
/// В общей библиотеке не показывается и метаданными не обогащается: у ролика нет ни года,
/// ни постера, а сотня роликов на экране шоу только мешала бы.
/// </summary>
Interstitial = 2,
}
@@ -0,0 +1,15 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Метка времени суток. Границ дейпартов нигде нет: у детского канала прайм в 17:00, у развлекательного
/// в 21:00, и хранить на канале четыре пары времён значило бы дублировать <see cref="Slot.TargetStart"/>.
/// Метка нужна для цвета в календаре, группировки в UI и области действия правил — что считать праймом,
/// решает администратор, ставя её на нужные слоты.
/// </summary>
public enum Daypart
{
Morning = 0,
Day = 1,
Prime = 2,
Night = 3,
}
@@ -0,0 +1,112 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Слой сетки: набор слотов, действующий при выполнении условия применимости. Для каждого момента
/// активен слот из применимого слоя с наибольшим <see cref="Priority"/>.
///
/// Фоновый слой (<see cref="Priority"/> = 0) покрывает сутки целиком и заменяет собой отдельную
/// сущность «заполнитель»: любая дыра в расписании — просто место, где сверху ничего не легло.
/// </summary>
public class GridLayer
{
private readonly List<Slot> _slots = new();
public Guid Id { get; private set; }
public Guid TemplateId { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Больше — специфичнее, побеждает. Фоновый слой имеет 0.</summary>
public int Priority { get; private set; }
/// <summary>Когда слой действует (JSON). Домен его не интерпретирует — схема в Application.</summary>
public string? ApplicabilityJson { get; private set; }
public bool IsEnabled { get; private set; }
/// <summary>Фоновый слой удалять нельзя — без него в сетке появляются дыры.</summary>
public bool IsBackground { get; private set; }
public IReadOnlyList<Slot> Slots => _slots;
public const int BackgroundPriority = 0;
private GridLayer() { }
internal static GridLayer Create(
Guid templateId,
string name,
int priority,
bool isBackground = false
) =>
new()
{
Id = Guid.NewGuid(),
TemplateId = templateId,
Name = name.Trim(),
Priority = isBackground ? BackgroundPriority : Math.Max(1, priority),
IsEnabled = true,
IsBackground = isBackground,
};
public void Update(string name, int priority, string? applicabilityJson, bool isEnabled)
{
Name = name.Trim();
// Приоритет фонового слоя не двигается: он обязан оставаться под всеми остальными.
if (!IsBackground)
Priority = Math.Max(1, priority);
ApplicabilityJson = string.IsNullOrWhiteSpace(applicabilityJson) ? null : applicabilityJson;
IsEnabled = isEnabled;
}
public Slot AddSlot(
string title,
TimeOnly targetStart,
int targetDurationMinutes,
Daypart daypart = Daypart.Day,
SlotKind slotKind = SlotKind.Content,
int? weekday = null
)
{
var slot = Slot.Create(
Id,
title,
targetStart,
targetDurationMinutes,
daypart,
slotKind,
weekday
);
_slots.Add(slot);
return slot;
}
public Slot? FindSlot(Guid slotId) => _slots.FirstOrDefault(s => s.Id == slotId);
public bool RemoveSlot(Guid slotId)
{
var slot = _slots.FirstOrDefault(s => s.Id == slotId);
if (slot is null)
return false;
_slots.Remove(slot);
return true;
}
/// <summary>
/// Пересекается ли интервал с уже существующими слотами того же дня. Внутри одного слоя
/// перекрытие запрещено: два слота на одну минуту сделали бы выбор недетерминированным.
/// Слоты на разные дни недели не конфликтуют; слот без дня недели («каждый день») конфликтует
/// со всеми.
/// </summary>
public bool HasOverlap(int? weekday, TimeOnly start, int durationMinutes, Guid? exceptSlotId)
{
var from = start.ToTimeSpan();
var to = from + TimeSpan.FromMinutes(durationMinutes);
return _slots.Any(other =>
other.Id != exceptSlotId
&& (weekday is null || other.Weekday is null || other.Weekday == weekday)
&& from < other.TargetEndOffset
&& other.TargetStart.ToTimeSpan() < to
);
}
}
@@ -0,0 +1,127 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Группа контента — «что может попасть в эфир». Слот планировщика ссылается на группу, стратегия
/// выбирает из неё элемент. Группы общие для всех каналов и переиспользуются.
///
/// Состав — всегда явный список (<see cref="Items"/>), а <see cref="FilterJson"/> — лишь правило
/// быстрого набора: «применить фильтр» добавляет найденное в список, дальше состав правится руками.
/// Живой запрос при генерации был бы проще в коде, но тогда добавление одного шоу в библиотеку
/// перетасовывало бы всё будущее расписание всех каналов.
/// </summary>
public class Group
{
private readonly List<GroupItem> _items = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string? Description { get; private set; }
/// <summary>Правило набора (JSON) или null. Домен его не интерпретирует — схема живёт в Application.</summary>
public string? FilterJson { get; private set; }
// ── Кэш статистики: считается при правке состава и фоново, нужен UI («342 позиции · 118 ч») ──
/// <summary>Число позиций в группе.</summary>
public int ItemCount { get; private set; }
/// <summary>Число единиц воспроизведения: у сериала и коллекции их больше одной.</summary>
public int UnitCount { get; private set; }
/// <summary>Суммарная длительность готовых единиц.</summary>
public TimeSpan TotalDuration { get; private set; }
public DateTimeOffset? StatsComputedAt { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Позиции группы (backing-field для EF); порядок — по <see cref="GroupItem.Position"/>.</summary>
public IReadOnlyList<GroupItem> Items => _items;
private Group() { }
public static Group Create(string name, string? description = null) =>
new()
{
Id = Guid.NewGuid(),
Name = name.Trim(),
Description = description,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Rename(string name, string? description)
{
Name = name.Trim();
Description = description;
}
/// <summary>Сохранить правило набора (null — снять). Состав при этом не меняется.</summary>
public void SetFilter(string? filterJson) =>
FilterJson = string.IsNullOrWhiteSpace(filterJson) ? null : filterJson;
/// <summary>Записать пересчитанную статистику.</summary>
public void UpdateStats(int itemCount, int unitCount, TimeSpan totalDuration, DateTimeOffset at)
{
ItemCount = itemCount;
UnitCount = unitCount;
TotalDuration = totalDuration;
StatsComputedAt = at;
}
public bool Contains(GroupElementKind kind, Guid elementId) =>
_items.Any(i => i.ElementKind == kind && i.ElementId == elementId);
/// <summary>Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null.</summary>
public GroupItem? AddElement(GroupElementKind kind, Guid elementId)
{
if (Contains(kind, elementId))
return null;
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
var item = GroupItem.Create(Id, kind, elementId, nextPosition);
_items.Add(item);
return item;
}
public bool RemoveItem(Guid itemId)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item is null)
return false;
_items.Remove(item);
return true;
}
/// <summary>Убирает все позиции, ссылающиеся на элемент. Вызывается при удалении шоу/коллекции
/// из библиотеки — внешнего ключа на полиморфную ссылку нет. Возвращает число снятых позиций.</summary>
public int RemoveElement(GroupElementKind kind, Guid elementId) =>
_items.RemoveAll(i => i.ElementKind == kind && i.ElementId == elementId);
public bool SetWeight(Guid itemId, int weight)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item is null)
return false;
item.SetWeight(weight);
return true;
}
/// <summary>
/// Переставляет позиции в порядке переданных идентификаторов. Не упомянутые остаются после них,
/// сохраняя относительный порядок, а неизвестные игнорируются — так перетаскивание одного
/// элемента не теряет остальные.
/// </summary>
public void Reorder(IEnumerable<Guid> itemIdsInOrder)
{
var known = _items.Select(i => i.Id).ToHashSet();
var requested = itemIdsInOrder.Distinct().Where(known.Contains).ToList();
var rest = _items
.Where(i => !requested.Contains(i.Id))
.OrderBy(i => i.Position)
.Select(i => i.Id);
var position = 0;
foreach (var itemId in requested.Concat(rest))
_items.First(i => i.Id == itemId).SetPosition(position++);
}
}
@@ -0,0 +1,8 @@
namespace TeleWave.Domain.Programming;
/// <summary>Что именно лежит в группе: шоу из библиотеки либо коллекция (франшиза) целиком.</summary>
public enum GroupElementKind
{
Show = 0,
Collection = 1,
}
@@ -0,0 +1,49 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Позиция группы: ссылка на шоу или коллекцию плюс вес и порядок.
///
/// <see cref="ElementId"/> — полиморфная ссылка, поэтому внешнего ключа на неё нет: удаление шоу
/// или коллекции из библиотеки чистит позиции команд удаления, а не каскадом БД.
/// </summary>
public class GroupItem
{
public Guid Id { get; private set; }
public Guid GroupId { get; private set; }
public GroupElementKind ElementKind { get; private set; }
public Guid ElementId { get; private set; }
/// <summary>
/// Вес при случайном выборе внутри группы (по умолчанию 1). Не конфликтует с остыванием, если
/// применять их в правильном порядке: сначала остывание отсекает недавно показанное, затем
/// взвешенный выбор работает среди оставшихся.
/// </summary>
public int Weight { get; private set; }
/// <summary>Порядок для последовательных стратегий (может иметь разрывы после удалений).</summary>
public int Position { get; private set; }
public const int DefaultWeight = 1;
private GroupItem() { }
internal static GroupItem Create(
Guid groupId,
GroupElementKind elementKind,
Guid elementId,
int position
) =>
new()
{
Id = Guid.NewGuid(),
GroupId = groupId,
ElementKind = elementKind,
ElementId = elementId,
Weight = DefaultWeight,
Position = position,
};
internal void SetPosition(int position) => Position = position;
internal void SetWeight(int weight) => Weight = Math.Max(0, weight);
}
@@ -0,0 +1,102 @@
namespace TeleWave.Domain.Programming;
/// <summary>Что за врезка стоит в стыке.</summary>
public enum JunctionElementKind
{
/// <summary>Реклама — ролики или готовые рекламные блоки из группы.</summary>
Ad = 0,
/// <summary>Анонс: «смотрите в четверг».</summary>
Promo = 1,
/// <summary>ТВ-заставка «Сейчас — Далее»: ассет рендерится под конкретную пару шоу.</summary>
Bumper = 2,
/// <summary>Заполнитель: закрывает остаток, когда врезки не добрали до цели.</summary>
Filler = 3,
}
/// <summary>Чем меряется врезка.</summary>
public enum JunctionAmountMode
{
/// <summary>Ровно N единиц. Предсказуемо по числу, но не по времени.</summary>
Count = 0,
/// <summary>
/// Пока не наберётся M минут. Нужен для смешанных групп, где рядом лежат и целые рекламные
/// блоки, и отдельные ролики: «одна единица» там означает то ли 20 секунд, то ли три минуты.
/// </summary>
Duration = 1,
}
/// <summary>
/// Врезка в шаблоне стыка. Условия показа хранятся структурно (<see cref="ConditionsJson"/>),
/// а не выражением: парсер, его валидация и отдельный UI обошлись бы дорого, а покрывают ровно
/// те же три-четыре реальных случая.
/// </summary>
public class JunctionElement
{
public Guid Id { get; private set; }
public Guid JunctionTemplateId { get; private set; }
public int Position { get; private set; }
public JunctionElementKind Kind { get; private set; }
/// <summary>Откуда брать единицы — для <see cref="JunctionElementKind.Ad"/>, <see cref="JunctionElementKind.Promo"/>, <see cref="JunctionElementKind.Filler"/>.</summary>
public Guid? GroupId { get; private set; }
/// <summary>Какой блок заставки рендерить — для <see cref="JunctionElementKind.Bumper"/>.</summary>
public Guid? BumperTemplateId { get; private set; }
public JunctionAmountMode AmountMode { get; private set; }
/// <summary>Единиц либо минут.</summary>
public int AmountValue { get; private set; }
/// <summary>Обязательную врезку нельзя выбросить при нехватке времени.</summary>
public bool IsRequired { get; private set; }
/// <summary>Условия показа (JSON). Домен их не интерпретирует — схема живёт в Application.</summary>
public string? ConditionsJson { get; private set; }
private JunctionElement() { }
internal static JunctionElement Create(
Guid junctionTemplateId,
int position,
JunctionElementKind kind
) =>
new()
{
Id = Guid.NewGuid(),
JunctionTemplateId = junctionTemplateId,
Position = position,
Kind = kind,
AmountMode = JunctionAmountMode.Count,
AmountValue = 1,
IsRequired = false,
};
public void Update(
JunctionElementKind kind,
Guid? groupId,
Guid? bumperTemplateId,
JunctionAmountMode amountMode,
int amountValue,
bool isRequired,
string? conditionsJson
)
{
Kind = kind;
AmountMode = amountMode;
AmountValue = Math.Max(1, amountValue);
IsRequired = isRequired;
ConditionsJson = string.IsNullOrWhiteSpace(conditionsJson) ? null : conditionsJson;
// Источник зависит от типа врезки: у заставки нет группы, у остальных нет блока заставки.
// Оставленная от прежнего типа ссылка потом читалась бы генератором как настройка.
GroupId = kind == JunctionElementKind.Bumper ? null : groupId;
BumperTemplateId = kind == JunctionElementKind.Bumper ? bumperTemplateId : null;
}
internal void SetPosition(int position) => Position = position;
}
@@ -0,0 +1,70 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Шаблон стыка: что играет между программами. Реклама и заставки перестают быть свойствами канала
/// и становятся врезками стыка — за счёт этого в прайм можно поставить три ролика и заставку,
/// а ночью один длинный ролик, чего одной настройкой на канал не сделать.
/// </summary>
public class JunctionTemplate
{
private readonly List<JunctionElement> _elements = new();
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public string Name { get; private set; } = string.Empty;
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Врезки в порядке показа (backing-field для EF).</summary>
public IReadOnlyList<JunctionElement> Elements => _elements;
private JunctionTemplate() { }
public static JunctionTemplate Create(Guid channelId, string name) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Name = name.Trim(),
CreatedAt = DateTimeOffset.UtcNow,
};
public void Rename(string name) => Name = name.Trim();
public JunctionElement AddElement(JunctionElementKind kind)
{
var nextPosition = _elements.Count == 0 ? 0 : _elements.Max(e => e.Position) + 1;
var element = JunctionElement.Create(Id, nextPosition, kind);
_elements.Add(element);
return element;
}
public JunctionElement? FindElement(Guid elementId) =>
_elements.FirstOrDefault(e => e.Id == elementId);
public bool RemoveElement(Guid elementId)
{
var element = _elements.FirstOrDefault(e => e.Id == elementId);
if (element is null)
return false;
_elements.Remove(element);
return true;
}
/// <summary>
/// Переставляет врезки в порядке переданных идентификаторов; не упомянутые остаются после них,
/// сохраняя относительный порядок. Порядок здесь — это порядок показа в эфире.
/// </summary>
public void Reorder(IEnumerable<Guid> elementIdsInOrder)
{
var known = _elements.Select(e => e.Id).ToHashSet();
var requested = elementIdsInOrder.Distinct().Where(known.Contains).ToList();
var rest = _elements
.Where(e => !requested.Contains(e.Id))
.OrderBy(e => e.Position)
.Select(e => e.Id);
var position = 0;
foreach (var id in requested.Concat(rest))
_elements.First(e => e.Id == id).SetPosition(position++);
}
}
@@ -0,0 +1,18 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Что делать, когда последовательность (обычно коллекция) не помещается в бюджет слота. Настройка
/// слота, а не коллекции: одна и та же трилогия в вечернем блоке может доигрываться по частям,
/// а в выходном марафоне — растягивать слот.
/// </summary>
public enum OverflowPolicy
{
/// <summary>Играем сколько влезло, курсор помнит середину, доигрываем в следующий выход.</summary>
ContinueNext = 0,
/// <summary>Играем целиком, слот залезает на соседей — ближайший якорь подберёт разбег.</summary>
ExtendSlot = 1,
/// <summary>Если целиком не влезает, берём из группы что-то другое.</summary>
SkipIfNotFits = 2,
}
@@ -0,0 +1,151 @@
using TeleWave.Domain.Broadcast.Scheduling;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>Что выбрано и почему — второе нужно для трейса «почему это здесь».</summary>
public sealed record ElementPick(
PlanningElement Element,
int StartUnitIndex,
int? CandidatesAfterCooldown,
bool CooldownExhausted
);
/// <summary>
/// Выбор элемента группы по стратегии слота. Отвечает только за выбор элемента: единицы внутри
/// элемента всегда идут по порядку от курсора — сериал не должен прыгать по сериям.
/// </summary>
public static class ElementSelector
{
public static ElementPick? Select(
PlanningSlot slot,
DateTimeOffset moment,
IRandomSource random
)
{
var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList();
if (playable.Count == 0)
return null;
// Курсор указывает на элемент, а не на индекс в группе: удаление позиции не сдвигает всё.
var current = slot.Cursor?.ElementId is { } currentId
? playable.FirstOrDefault(e =>
e.ElementId == currentId && e.Kind == slot.Cursor.ElementKind
)
: null;
return slot.Strategy.Kind switch
{
SlotStrategyKind.Fixed => SelectFixed(slot, playable),
SlotStrategyKind.Sequential => SelectSequential(slot, playable, current),
_ => SelectRandom(slot, playable, current, moment, random),
};
}
private static ElementPick? SelectFixed(PlanningSlot slot, List<PlanningElement> playable)
{
var fixedElement = playable.FirstOrDefault(e =>
e.ElementId == slot.Strategy.FixedElementId
);
return fixedElement is null ? null : Continue(slot, fixedElement);
}
/// <summary>
/// Последовательная стратегия: доигрываем текущий элемент, а исчерпав его — берём следующий
/// по позиции. Дойдя до конца группы, начинаем сначала либо останавливаемся.
/// </summary>
private static ElementPick? SelectSequential(
PlanningSlot slot,
List<PlanningElement> playable,
PlanningElement? current
)
{
var ordered = playable.OrderBy(e => e.Position).ToList();
if (current is not null && HasUnitsLeft(slot, current))
return Continue(slot, current);
var currentIndex = current is null ? -1 : ordered.FindIndex(e => e.ElementId == current.ElementId);
var nextIndex = currentIndex + 1;
if (nextIndex >= ordered.Count)
{
if (!slot.Strategy.RestartOnEnd)
return null;
nextIndex = 0;
}
return new ElementPick(ordered[nextIndex], 0, null, false);
}
/// <summary>
/// Случайный выбор с остыванием. Порядок принципиален: сначала остывание отсекает недавно
/// показанное (жёсткий фильтр «нельзя»), затем взвешенный выбор работает среди оставшихся.
/// В обратном порядке вес постоянно упирался бы в кулдаун и не работал.
/// </summary>
private static ElementPick? SelectRandom(
PlanningSlot slot,
List<PlanningElement> playable,
PlanningElement? current,
DateTimeOffset moment,
IRandomSource random
)
{
// Начатый элемент доигрывается до конца: иначе блок «4 серии подряд» рассыпался бы
// на серии из разных сериалов.
if (current is not null && HasUnitsLeft(slot, current))
return Continue(slot, current);
var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
var eligible = cooldown <= TimeSpan.Zero
? playable
: playable
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
.ToList();
var exhausted = eligible.Count == 0;
if (exhausted)
{
// Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
// пустой эфир хуже раннего повтора в обоих случаях.
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
? playable
: [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
}
var picked = WeightedPick(eligible, random);
return new ElementPick(picked, 0, eligible.Count, exhausted);
}
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
{
var index = slot.Cursor?.ElementId == element.ElementId ? slot.Cursor.NextUnitIndex : 0;
return new ElementPick(element, Math.Clamp(index, 0, element.Units.Count), null, false);
}
private static bool HasUnitsLeft(PlanningSlot slot, PlanningElement element) =>
slot.Cursor is { } cursor
&& cursor.ElementId == element.ElementId
&& cursor.NextUnitIndex < element.Units.Count;
private static PlanningElement WeightedPick(
IReadOnlyList<PlanningElement> candidates,
IRandomSource random
)
{
var total = candidates.Sum(c => (long)Math.Max(0, c.Weight));
if (total <= 0)
return candidates[random.Next(candidates.Count)];
var roll = random.Next((int)Math.Min(total, int.MaxValue));
long accumulated = 0;
foreach (var candidate in candidates)
{
accumulated += Math.Max(0, candidate.Weight);
if (roll < accumulated)
return candidate;
}
return candidates[^1];
}
}
@@ -0,0 +1,172 @@
namespace TeleWave.Domain.Programming.Planning;
/// <summary>Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз.</summary>
public sealed class JunctionHistory
{
private readonly Dictionary<JunctionElementKind, DateTimeOffset> _lastPlaced = new();
public bool Allows(PlanningJunctionElement element, DateTimeOffset moment)
{
if (element.MinMinutesBetween <= 0)
return true;
if (!_lastPlaced.TryGetValue(element.Kind, out var last))
return true;
return moment - last >= TimeSpan.FromMinutes(element.MinMinutesBetween);
}
public void Record(PlanningJunctionElement element, DateTimeOffset moment) =>
_lastPlaced[element.Kind] = moment;
}
/// <summary>
/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. Ставит только то, что влезает
/// целиком до предела (якорь или горизонт) — обрезать врезку нельзя, а перехлёст сдвинул бы якорь.
///
/// Обязательные врезки (<see cref="PlanningJunctionElement.IsRequired"/>) идут первыми: при нехватке
/// времени выбрасываются необязательные, а не то, ради чего стык и заведён.
/// </summary>
public static class JunctionFiller
{
/// <summary>
/// Раскладывает стык от <paramref name="cursor"/>. <paramref name="fromShowId"/>/<paramref name="toShowId"/>
/// нужны заставке: её ассет зависит от пары соседей и рендерится после сборки ленты.
/// Возвращает курсор после стыка.
/// </summary>
public static DateTimeOffset Fill(
PlanningJunction? junction,
DateTimeOffset cursor,
DateTimeOffset limit,
Guid slotId,
Guid? fromShowId,
Guid? toShowId,
bool elementChanged,
JunctionHistory history,
List<PlannedItem> items,
PlanTrace? trace
)
{
if (junction is null || junction.Elements.Count == 0)
return cursor;
var ordered = junction
.Elements.OrderByDescending(e => e.IsRequired)
.ThenBy(e => junction.Elements.ToList().IndexOf(e))
.ToList();
foreach (var element in ordered)
{
if (element.OnlyOnElementChange && !elementChanged)
continue;
if (!history.Allows(element, cursor))
continue;
var placedAt = cursor;
cursor =
element.Kind == JunctionElementKind.Bumper
? PlaceBumper(element, cursor, limit, slotId, fromShowId, toShowId, items, trace)
: PlaceUnits(element, cursor, limit, slotId, items, trace);
if (cursor > placedAt)
history.Record(element, placedAt);
}
return cursor;
}
/// <summary>
/// Резервирует место под заставку. Ассет пуст: он рендерится под конкретную пару «из/в» уже после
/// того, как лента собрана, — до наполнения слотов пара попросту неизвестна.
/// </summary>
private static DateTimeOffset PlaceBumper(
PlanningJunctionElement element,
DateTimeOffset cursor,
DateTimeOffset limit,
Guid slotId,
Guid? fromShowId,
Guid? toShowId,
List<PlannedItem> items,
PlanTrace? trace
)
{
if (element.BumperDuration <= TimeSpan.Zero || cursor + element.BumperDuration > limit)
return cursor;
var end = cursor + element.BumperDuration;
items.Add(
new PlannedItem(
Guid.Empty,
cursor,
end,
toShowId,
null,
slotId,
PlannedItemKind.Bumper,
trace,
element.BumperTemplateId,
fromShowId,
toShowId
)
);
return end;
}
/// <summary>Ставит единицы врезки по её бюджету: N штук либо пока не наберётся M минут.</summary>
private static DateTimeOffset PlaceUnits(
PlanningJunctionElement element,
DateTimeOffset cursor,
DateTimeOffset limit,
Guid slotId,
List<PlannedItem> items,
PlanTrace? trace
)
{
if (element.Units.Count == 0)
return cursor;
var kind = element.Kind switch
{
JunctionElementKind.Ad => PlannedItemKind.Ad,
JunctionElementKind.Promo => PlannedItemKind.Promo,
_ => PlannedItemKind.Fallback,
};
var placed = 0;
var accumulated = TimeSpan.Zero;
var index = 0;
while (index < element.Units.Count)
{
var unit = element.Units[index];
index++;
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > limit)
break;
var enough =
element.AmountMode == JunctionAmountMode.Count
? placed >= Math.Max(1, element.AmountValue)
// Последняя единица входит целиком: рекламный блок не разрезается.
: accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue));
if (enough)
break;
items.Add(
new PlannedItem(
unit.MediaAssetId,
cursor,
cursor + unit.Duration,
null,
null,
slotId,
kind,
trace
)
);
cursor += unit.Duration;
accumulated += unit.Duration;
placed++;
}
return cursor;
}
}
@@ -0,0 +1,192 @@
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не
/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям.
/// </summary>
public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex);
/// <summary>
/// Элемент группы, развёрнутый в последовательность единиц. <see cref="LastPlayedUtc"/> — когда он
/// в последний раз выходил в этом канале; по нему работает остывание.
/// </summary>
public sealed record PlanningElement(
GroupElementKind Kind,
Guid ElementId,
int Weight,
int Position,
IReadOnlyList<PlanningUnit> Units,
DateTimeOffset? LastPlayedUtc = null
);
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
public sealed record PlanningStrategy(
SlotStrategyKind Kind,
bool RestartOnEnd = true,
int CooldownDays = 0,
bool IgnoreCooldownWhenExhausted = false,
Guid? FixedElementId = null
);
/// <summary>Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.</summary>
public enum SlotStrategyKind
{
Sequential = 0,
RandomWithCooldown = 1,
Fixed = 2,
}
/// <summary>Где остановились в текущем элементе на момент начала прогона.</summary>
public sealed record PlanningCursor(
GroupElementKind? ElementKind,
Guid? ElementId,
int NextUnitIndex
);
/// <summary>
/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают
/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC.
/// </summary>
public sealed record PlanningSlot(
Guid SlotId,
DateTimeOffset TargetStartUtc,
int TargetDurationMinutes,
SlotKind SlotKind,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
PlanningStrategy Strategy,
IReadOnlyList<PlanningElement> Elements,
PlanningCursor? Cursor,
/// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary>
IReadOnlyList<PlanningUnit>? RepeatUnits = null,
/// <summary>Врезки между единицами внутри блока.</summary>
PlanningJunction? JunctionBetween = null,
/// <summary>Врезки в конце блока.</summary>
PlanningJunction? JunctionAfter = null
)
{
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
}
/// <summary>
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
/// решить, сколько их поставить и влезают ли они.
/// </summary>
public sealed record PlanningJunctionElement(
JunctionElementKind Kind,
IReadOnlyList<PlanningUnit> Units,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
/// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary>
bool OnlyOnElementChange = false,
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
int MinMinutesBetween = 0,
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
Guid? BumperTemplateId = null,
/// <summary>Длительность резерва под заставку.</summary>
TimeSpan BumperDuration = default
);
/// <summary>Стык: последовательность врезок между программами.</summary>
public sealed record PlanningJunction(
Guid JunctionId,
IReadOnlyList<PlanningJunctionElement> Elements
);
/// <summary>Полный вход одного прогона генератора.</summary>
public sealed record PlanningInput(
Guid ChannelId,
DateTimeOffset StartUtc,
DateTimeOffset HorizonEndUtc,
IReadOnlyList<PlanningSlot> Slots,
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
IReadOnlyList<PlanningUnit> FallbackUnits,
int SegmentSeconds
);
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
public sealed record PlannedItem(
Guid MediaAssetId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
int? UnitIndex,
Guid? SlotId,
PlannedItemKind Kind,
PlanTrace? Trace = null,
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
Guid? BumperTemplateId = null,
Guid? FromShowId = null,
Guid? ToShowId = null
);
public enum PlannedItemKind
{
Program = 0,
Fallback = 1,
SignOff = 2,
Ad = 3,
Promo = 4,
/// <summary>
/// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента
/// собрана, — планировщик лишь резервирует под неё длительность.
/// </summary>
Bumper = 5,
}
/// <summary>Цепочка происхождения записи — питает экран «почему это здесь».</summary>
public sealed record PlanTrace(
Guid? SlotId,
SlotKind SlotKind,
GroupElementKind? ElementKind,
Guid? ElementId,
SlotStrategyKind? Strategy,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор без остывания).</summary>
int? CandidatesAfterCooldown,
/// <summary>Насколько фактический старт разошёлся с целевым, минуты.</summary>
int DriftMinutes,
/// <summary>Старт сдвинут вперёд округлением до круглого времени.</summary>
bool Snapped
);
/// <summary>Новое состояние слота после прогона — оркестратор сохраняет его в БД.</summary>
public sealed record PlanningCursorUpdate(
Guid SlotId,
GroupElementKind? ElementKind,
Guid? ElementId,
int NextUnitIndex
);
/// <summary>Результат прогона: лента, новые курсоры и предупреждения для админа.</summary>
public sealed record PlanningResult(
IReadOnlyList<PlannedItem> Items,
IReadOnlyList<PlanningCursorUpdate> Cursors,
IReadOnlyList<PlanningWarning> Warnings
);
/// <summary>Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.</summary>
public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details);
public enum PlanningWarningKind
{
/// <summary>Слот не дал контента — место закрыл фон.</summary>
SlotEmpty = 0,
/// <summary>Фактический старт ушёл дальше допуска.</summary>
DriftExceeded = 1,
/// <summary>Остывание отсекло всех кандидатов.</summary>
CooldownExhausted = 2,
/// <summary>Не нашлось, что повторить.</summary>
RepeatSourceEmpty = 3,
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
FallbackEmpty = 4,
}
@@ -0,0 +1,487 @@
using TeleWave.Domain.Broadcast.Scheduling;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/>
/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо.
///
/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по
/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым
/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени).
/// </summary>
public static class SchedulePlanner
{
private const int IterationBackstop = 100_000;
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
{
var items = new List<PlannedItem>();
var cursors = new List<PlanningCursorUpdate>();
var warnings = new List<PlanningWarning>();
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
var cursor = input.StartUtc;
var iterations = 0;
// История врезок общая на прогон: «не чаще раза в полчаса» должно работать и через границу слота.
var junctions = new JunctionHistory();
Guid? previousShowId = null;
for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
{
if (iterations++ > IterationBackstop)
break;
var slot = slots[i];
// Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего,
// а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше.
if (slot.TargetEndUtc <= cursor)
continue;
var nextAnchor = FindNextAnchor(slots, i + 1);
cursor = OpenSlot(slot, cursor, input, items, warnings, out var trace);
if (cursor >= input.HorizonEndUtc)
break;
cursor = FillSlot(
slot,
cursor,
nextAnchor,
input,
random,
items,
cursors,
warnings,
trace,
junctions,
ref previousShowId
);
}
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
// упрётся в дыру.
if (cursor < input.HorizonEndUtc)
cursor = FillWithFallback(cursor, input.HorizonEndUtc, input, items, null);
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
warnings.Add(
new PlanningWarning(
PlanningWarningKind.FallbackEmpty,
null,
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
)
);
return new PlanningResult(items, cursors, warnings);
}
/// <summary>
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
/// фактический старт и заполняет трейс сведениями о дрейфе.
/// </summary>
private static DateTimeOffset OpenSlot(
PlanningSlot slot,
DateTimeOffset cursor,
PlanningInput input,
List<PlannedItem> items,
List<PlanningWarning> warnings,
out PlanTrace trace
)
{
var snapped = false;
if (cursor < slot.TargetStartUtc)
{
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
cursor = FillWithFallback(cursor, slot.TargetStartUtc, input, items, slot.SlotId);
}
else if (slot.SnapToMinutes is { } snap && snap > 0)
{
// Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше
// начать в 19:47, чем девять минут крутить фон.
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
{
var afterFill = FillWithFallback(cursor, target, input, items, slot.SlotId);
snapped = afterFill > cursor;
cursor = afterFill;
}
}
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
if (Math.Abs(drift) > slot.MaxDriftMinutes)
warnings.Add(
new PlanningWarning(
PlanningWarningKind.DriftExceeded,
slot.SlotId,
$"Фактический старт разошёлся с целевым на {drift} мин."
)
);
trace = new PlanTrace(
slot.SlotId,
slot.SlotKind,
null,
null,
null,
null,
drift,
snapped
);
return cursor;
}
/// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary>
private static DateTimeOffset FillSlot(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset? nextAnchor,
PlanningInput input,
IRandomSource random,
List<PlannedItem> items,
List<PlanningCursorUpdate> cursors,
List<PlanningWarning> warnings,
PlanTrace trace,
JunctionHistory junctions,
ref Guid? previousShowId
)
{
var limit = Min(input.HorizonEndUtc, nextAnchor);
switch (slot.SlotKind)
{
case SlotKind.SignOff:
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
return FillWithFallback(
cursor,
Min(slot.TargetEndUtc, limit),
input,
items,
slot.SlotId,
PlannedItemKind.SignOff,
trace
);
case SlotKind.Repeat:
return FillRepeat(slot, cursor, limit, input, items, warnings, trace);
default:
return FillContent(
slot,
cursor,
limit,
input,
random,
items,
cursors,
warnings,
trace,
junctions,
ref previousShowId
);
}
}
private static DateTimeOffset FillRepeat(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanningInput input,
List<PlannedItem> items,
List<PlanningWarning> warnings,
PlanTrace trace
)
{
var units = slot.RepeatUnits ?? [];
if (units.Count == 0)
{
warnings.Add(
new PlanningWarning(
PlanningWarningKind.RepeatSourceEmpty,
slot.SlotId,
"В источнике повтора ничего не нашлось — слот закрыт фоном."
)
);
return FillWithFallback(
cursor,
Min(slot.TargetEndUtc, limit),
input,
items,
slot.SlotId
);
}
var slotEnd = Min(slot.TargetEndUtc, limit);
foreach (var unit in units)
{
if (cursor + unit.Duration > slotEnd)
break;
items.Add(Program(unit, cursor, slot.SlotId, trace));
cursor += unit.Duration;
}
return cursor;
}
private static DateTimeOffset FillContent(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanningInput input,
IRandomSource random,
List<PlannedItem> items,
List<PlanningCursorUpdate> cursors,
List<PlanningWarning> warnings,
PlanTrace trace,
JunctionHistory junctions,
ref Guid? previousShowId
)
{
var pick = ElementSelector.Select(slot, cursor, random);
if (pick is null)
{
warnings.Add(
new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
)
);
return FillWithFallback(
cursor,
Min(slot.TargetEndUtc, limit),
input,
items,
slot.SlotId
);
}
if (pick.CooldownExhausted)
warnings.Add(
new PlanningWarning(
PlanningWarningKind.CooldownExhausted,
slot.SlotId,
"Остывание отсекло всех кандидатов — взят самый давний."
)
);
var element = pick.Element;
var slotTrace = trace with
{
ElementKind = element.Kind,
ElementId = element.ElementId,
Strategy = slot.Strategy.Kind,
CandidatesAfterCooldown = pick.CandidatesAfterCooldown,
};
var unitIndex = pick.StartUnitIndex;
var budgetEnd = Min(slot.TargetEndUtc, limit);
var placed = 0;
var accumulated = TimeSpan.Zero;
// SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают.
if (
slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd)
)
{
cursors.Add(CursorUpdate(slot, element, unitIndex));
return FillWithFallback(cursor, budgetEnd, input, items, slot.SlotId);
}
while (unitIndex < element.Units.Count && cursor < limit)
{
var unit = element.Units[unitIndex];
// Врезки между единицами: перед каждой, кроме первой в блоке.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionBetween,
cursor,
limit,
slot.SlotId,
previousShowId,
unit.ShowId,
elementChanged: previousShowId != unit.ShowId,
junctions,
items,
slotTrace
);
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
if (cursor + unit.Duration > limit)
break;
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break;
items.Add(Program(unit, cursor, slot.SlotId, slotTrace));
cursor += unit.Duration;
accumulated += unit.Duration;
unitIndex++;
placed++;
previousShowId = unit.ShowId;
}
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionAfter,
cursor,
limit,
slot.SlotId,
previousShowId,
null,
elementChanged: true,
junctions,
items,
slotTrace
);
cursors.Add(CursorUpdate(slot, element, unitIndex));
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
cursor = FillWithFallback(cursor, budgetEnd, input, items, slot.SlotId);
return cursor;
}
/// <summary>
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
/// </summary>
private static bool WithinBudget(
PlanningSlot slot,
int placed,
TimeSpan accumulated,
DateTimeOffset cursor,
PlanningUnit unit,
DateTimeOffset budgetEnd
)
{
if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot)
return true;
return slot.BlockMode switch
{
SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue),
// Последняя единица входит целиком: обрезать видеофайл нельзя.
SlotBlockMode.Duration => accumulated < TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)),
_ => cursor + unit.Duration <= budgetEnd,
};
}
private static bool FitsEntirely(
PlanningElement element,
int fromUnitIndex,
DateTimeOffset cursor,
DateTimeOffset budgetEnd
)
{
var total = element
.Units.Skip(fromUnitIndex)
.Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration);
return cursor + total <= budgetEnd;
}
/// <summary>
/// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
/// остаётся незакрытым — раздача покажет там аварийный филлер канала.
/// </summary>
private static DateTimeOffset FillWithFallback(
DateTimeOffset from,
DateTimeOffset until,
PlanningInput input,
List<PlannedItem> items,
Guid? slotId,
PlannedItemKind kind = PlannedItemKind.Fallback,
PlanTrace? trace = null
)
{
if (input.FallbackUnits.Count == 0 || until <= from)
return from;
var cursor = from;
var index = 0;
var guard = 0;
while (cursor < until && guard++ < IterationBackstop)
{
var unit = input.FallbackUnits[index % input.FallbackUnits.Count];
index++;
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
break;
items.Add(
new PlannedItem(
unit.MediaAssetId,
cursor,
cursor + unit.Duration,
null,
null,
slotId,
kind,
trace
)
);
cursor += unit.Duration;
}
return cursor;
}
private static PlannedItem Program(
PlanningUnit unit,
DateTimeOffset start,
Guid slotId,
PlanTrace trace
) =>
new(
unit.MediaAssetId,
start,
start + unit.Duration,
unit.ShowId,
unit.UnitIndex,
slotId,
PlannedItemKind.Program,
trace
);
private static PlanningCursorUpdate CursorUpdate(
PlanningSlot slot,
PlanningElement element,
int nextUnitIndex
) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex);
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
{
for (var i = fromIndex; i < slots.Count; i++)
if (slots[i].IsAnchor)
return slots[i].TargetStartUtc;
return null;
}
private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) =>
other is { } o && o < value ? o : value;
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b;
/// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary>
private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step)
{
if (step <= TimeSpan.Zero)
return moment;
var ticks = step.Ticks;
var remainder = moment.UtcTicks % ticks;
return remainder == 0 ? moment : moment.AddTicks(ticks - remainder);
}
}
@@ -0,0 +1,96 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
/// для одного и того же. На другой канал переносится глубокой копией.
///
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
/// шаблон изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public class ScheduleTemplate
{
private readonly List<GridLayer> _layers = new();
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Аварийная группа, если пуст даже фоновый слой.</summary>
public Guid? FallbackGroupId { get; private set; }
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
public Guid? DefaultJunctionId { get; private set; }
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
public int Revision { get; private set; }
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
public int AppliedRevision { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyList<GridLayer> Layers => _layers;
/// <summary>Есть ли правки, не применённые к эфиру.</summary>
public bool HasPendingChanges => Revision != AppliedRevision;
private const string BackgroundLayerName = "Фон";
private ScheduleTemplate() { }
public static ScheduleTemplate Create(Guid channelId, string name)
{
var template = new ScheduleTemplate
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Name = name.Trim(),
Revision = 0,
AppliedRevision = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
// Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть.
template._layers.Add(
GridLayer.Create(template.Id, BackgroundLayerName, GridLayer.BackgroundPriority, isBackground: true)
);
return template;
}
public void Rename(string name) => Name = name.Trim();
public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId;
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
public void MarkChanged() => Revision++;
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
public void MarkApplied() => AppliedRevision = Revision;
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground);
public GridLayer AddLayer(string name, int priority)
{
var layer = GridLayer.Create(Id, name, priority);
_layers.Add(layer);
return layer;
}
/// <summary>Удаляет слой. Фоновый удалить нельзя — вернёт false.</summary>
public bool RemoveLayer(Guid layerId)
{
var layer = _layers.FirstOrDefault(l => l.Id == layerId);
if (layer is null || layer.IsBackground)
return false;
_layers.Remove(layer);
return true;
}
/// <summary>Слой, содержащий слот, или null.</summary>
public GridLayer? FindLayerOfSlot(Guid slotId) =>
_layers.FirstOrDefault(l => l.FindSlot(slotId) is not null);
}
@@ -0,0 +1,142 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык,
/// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат
/// якоря (<see cref="IsAnchor"/>) и мягкое округление (<see cref="SnapToMinutes"/>).
/// </summary>
public class Slot
{
public Guid Id { get; private set; }
public Guid LayerId { get; private set; }
/// <summary>День недели вещательных суток (0=Вс..6=Сб) или null — каждый день.</summary>
public int? Weekday { get; private set; }
/// <summary>Целевое время старта в сутках канала.</summary>
public TimeOnly TargetStart { get; private set; }
/// <summary>Бюджет слота в минутах.</summary>
public int TargetDurationMinutes { get; private set; }
public string Title { get; private set; } = string.Empty;
public Daypart Daypart { get; private set; }
public SlotKind SlotKind { get; private set; }
/// <summary>Группа контента — для <see cref="SlotKind.Content"/>.</summary>
public Guid? GroupId { get; private set; }
/// <summary>Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application.</summary>
public string? StrategyJson { get; private set; }
/// <summary>Откуда брать повтор (JSON) — для <see cref="SlotKind.Repeat"/>.</summary>
public string? RepeatSourceJson { get; private set; }
public SlotBlockMode BlockMode { get; private set; }
/// <summary>Единиц (<see cref="SlotBlockMode.Count"/>) или минут (<see cref="SlotBlockMode.Duration"/>).</summary>
public int BlockValue { get; private set; }
public OverflowPolicy OverflowPolicy { get; private set; }
/// <summary>Старт жёсткий: генератор не начнёт единицу, которая через него перелезет.</summary>
public bool IsAnchor { get; private set; }
/// <summary>Допуск отклонения фактического старта от целевого, минуты.</summary>
public int MaxDriftMinutes { get; private set; }
/// <summary>
/// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если
/// добирать пришлось бы дольше <see cref="MaxDriftMinutes"/>, округление пропускается.
/// </summary>
public int? SnapToMinutes { get; private set; }
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
public Guid? JunctionBetweenId { get; private set; }
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
public Guid? JunctionAfterId { get; private set; }
public const int DefaultMaxDriftMinutes = 5;
private Slot() { }
public static Slot Create(
Guid layerId,
string title,
TimeOnly targetStart,
int targetDurationMinutes,
Daypart daypart = Daypart.Day,
SlotKind slotKind = SlotKind.Content,
int? weekday = null
) =>
new()
{
Id = Guid.NewGuid(),
LayerId = layerId,
Title = title.Trim(),
TargetStart = targetStart,
TargetDurationMinutes = Math.Max(1, targetDurationMinutes),
Daypart = daypart,
SlotKind = slotKind,
Weekday = weekday,
BlockMode = SlotBlockMode.FillSlot,
BlockValue = 1,
OverflowPolicy = OverflowPolicy.ContinueNext,
IsAnchor = false,
MaxDriftMinutes = DefaultMaxDriftMinutes,
};
/// <summary>Правит расписание слота: когда, сколько и как выравнивать.</summary>
public void UpdateTiming(
int? weekday,
TimeOnly targetStart,
int targetDurationMinutes,
Daypart daypart,
bool isAnchor,
int maxDriftMinutes,
int? snapToMinutes
)
{
Weekday = weekday is >= 0 and <= 6 ? weekday : null;
TargetStart = targetStart;
TargetDurationMinutes = Math.Max(1, targetDurationMinutes);
Daypart = daypart;
IsAnchor = isAnchor;
MaxDriftMinutes = Math.Max(0, maxDriftMinutes);
SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null;
}
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
public void UpdateContent(
string title,
SlotKind slotKind,
Guid? groupId,
string? strategyJson,
string? repeatSourceJson,
SlotBlockMode blockMode,
int blockValue,
OverflowPolicy overflowPolicy,
Guid? junctionBetweenId = null,
Guid? junctionAfterId = null
)
{
JunctionBetweenId = junctionBetweenId;
JunctionAfterId = junctionAfterId;
Title = title.Trim();
SlotKind = slotKind;
BlockMode = blockMode;
BlockValue = Math.Max(1, blockValue);
OverflowPolicy = overflowPolicy;
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
GroupId = slotKind == SlotKind.Content ? groupId : null;
StrategyJson = slotKind == SlotKind.Content ? strategyJson : null;
RepeatSourceJson = slotKind == SlotKind.Repeat ? repeatSourceJson : null;
}
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
public TimeSpan TargetEndOffset =>
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
}
@@ -0,0 +1,18 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Чем меряется блок, который слот берёт за один выход. Свойство слота, ортогональное стратегии:
/// любую стратегию можно скомбинировать с «одна единица», «четыре подряд» или «пока не наберётся
/// два часа». Марафон — это <see cref="FillSlot"/>, отдельной стратегии не нужно.
/// </summary>
public enum SlotBlockMode
{
/// <summary>Ровно N единиц воспроизведения.</summary>
Count = 0,
/// <summary>Единицы, пока сумма не превысит M минут (последняя входит целиком).</summary>
Duration = 1,
/// <summary>Пока не исчерпан бюджет слота.</summary>
FillSlot = 2,
}
@@ -0,0 +1,20 @@
namespace TeleWave.Domain.Programming;
/// <summary>Тип слота: обычный контент, повтор уже сыгранного или конец вещания.</summary>
public enum SlotKind
{
/// <summary>Контент из группы по стратегии слота.</summary>
Content = 0,
/// <summary>
/// Повтор того, что уже играло в этой же ленте (вечерний фильм утром в субботу). Стратегии
/// и состояния не требует — читает записанное расписание.
/// </summary>
Repeat = 1,
/// <summary>
/// Конец вещания: настроечная таблица или гимн вместо эфира. Сделано слотом, а не флагом канала,
/// чтобы конец вещания можно было поставить только в будни либо не ставить вовсе.
/// </summary>
SignOff = 2,
}
@@ -0,0 +1,42 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Состояние слота: где остановились в текущем элементе. Курсор хранит ссылку на элемент, а не
/// числовой индекс в группе, — при удалении позиции из группы он корректно переезжает на следующую,
/// а не сдвигает всё.
///
/// Состояние существует потому, что сетка эластичная: сколько единиц уйдёт за один выход слота,
/// зависит от фактических длительностей, и вычислить это арифметически от эпохи нельзя.
/// </summary>
public class SlotState
{
public Guid SlotId { get; private set; }
public GroupElementKind? CurrentElementKind { get; private set; }
public Guid? CurrentElementId { get; private set; }
/// <summary>Позиция следующей единицы внутри текущего элемента.</summary>
public int NextUnitIndex { get; private set; }
private SlotState() { }
public static SlotState Create(Guid slotId) => new() { SlotId = slotId, NextUnitIndex = 0 };
/// <summary>Продолжить текущий элемент со следующей единицы.</summary>
public void Advance(int nextUnitIndex) => NextUnitIndex = Math.Max(0, nextUnitIndex);
/// <summary>Перейти к другому элементу, начав его с указанной единицы.</summary>
public void MoveTo(GroupElementKind kind, Guid elementId, int nextUnitIndex = 0)
{
CurrentElementKind = kind;
CurrentElementId = elementId;
NextUnitIndex = Math.Max(0, nextUnitIndex);
}
/// <summary>Забыть текущий элемент — например, когда он исчез из группы.</summary>
public void Reset()
{
CurrentElementKind = null;
CurrentElementId = null;
NextUnitIndex = 0;
}
}