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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user