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