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