Normalize line endings to LF via .gitattributes

Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка
работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево,
чтобы форматтеры не переписывали файлы целиком на каждом прогоне.

Коммит чисто механический: изменений содержимого нет, только концы строк.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:37:33 +03:00
co-authored by Claude Opus 5
parent 9d1c6d2fc3
commit 0442056367
109 changed files with 13469 additions and 13459 deletions
+179 -179
View File
@@ -1,179 +1,179 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
/// филлер и общие настройки заставок.
/// </summary>
public class Channel
{
private readonly List<BumperTemplate> _bumperTemplates = [];
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Slug { get; private set; } = string.Empty;
public bool IsEnabled { get; private set; }
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { 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);
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
// живут в элементах стыка; на канале осталось только общее для всех заставок. ──
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
public bool BumpersEnabled { get; private set; }
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; }
public BumperFont BumperFont { get; private set; }
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
/// <summary>Логотип-оверлей: ссылка на реестр изображений или null (логотипа нет).</summary>
public Guid? LogoImageId { get; private set; }
public LogoCorner LogoCorner { get; private set; }
/// <summary>Прозрачность логотипа, 0..1.</summary>
public double LogoOpacity { get; private set; } = 0.8;
/// <summary>Показывать ли часы поверх картинки.</summary>
public bool ShowClock { get; private set; }
/// <summary>Сила аналогового фильтра, 0..1 (0 — выключен).</summary>
public double AnalogFilterStrength { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
private Channel() { }
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
{
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug,
IsEnabled = true,
EpochUtc = epochUtc,
BumpersEnabled = false,
BumperSelection = BumperSelection.WeightedRandom,
BumperFont = BumperFont.Sans,
LogoOpacity = 0.8,
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
DayStartTime = DefaultDayStartTime,
CreatedAt = DateTimeOffset.UtcNow,
};
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
return channel;
}
public void UpdateSettings(
string name,
bool isEnabled,
bool bumpersEnabled,
Guid? fillerAssetId
)
{
Name = name;
IsEnabled = isEnabled;
BumpersEnabled = bumpersEnabled;
FillerAssetId = fillerAssetId;
}
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
{
BumperFont = font;
BumperSelection = selection;
}
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
public void UpdateViewerSettings(
Guid? logoImageId,
LogoCorner logoCorner,
double logoOpacity,
bool showClock,
double analogFilterStrength
)
{
LogoImageId = logoImageId;
LogoCorner = logoCorner;
LogoOpacity = Math.Clamp(logoOpacity, 0.0, 1.0);
ShowClock = showClock;
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
}
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
public BumperTemplate AddBumperTemplate(string name)
{
var nextPosition =
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
var template = BumperTemplate.Create(Id, nextPosition, name);
_bumperTemplates.Add(template);
return template;
}
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
public bool RemoveBumperTemplate(Guid templateId)
{
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
if (template is null || template.IsDefault)
return false;
_bumperTemplates.Remove(template);
return true;
}
/// <summary>Привязать активный шаблон сетки.</summary>
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
/// <summary>
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
/// </summary>
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
{
Number = number is > 0 ? number : null;
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
DayStartTime = dayStartTime;
}
}
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
/// филлер и общие настройки заставок.
/// </summary>
public class Channel
{
private readonly List<BumperTemplate> _bumperTemplates = [];
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Slug { get; private set; } = string.Empty;
public bool IsEnabled { get; private set; }
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { 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);
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
// живут в элементах стыка; на канале осталось только общее для всех заставок. ──
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
public bool BumpersEnabled { get; private set; }
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; }
public BumperFont BumperFont { get; private set; }
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
/// <summary>Логотип-оверлей: ссылка на реестр изображений или null (логотипа нет).</summary>
public Guid? LogoImageId { get; private set; }
public LogoCorner LogoCorner { get; private set; }
/// <summary>Прозрачность логотипа, 0..1.</summary>
public double LogoOpacity { get; private set; } = 0.8;
/// <summary>Показывать ли часы поверх картинки.</summary>
public bool ShowClock { get; private set; }
/// <summary>Сила аналогового фильтра, 0..1 (0 — выключен).</summary>
public double AnalogFilterStrength { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
private Channel() { }
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
{
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug,
IsEnabled = true,
EpochUtc = epochUtc,
BumpersEnabled = false,
BumperSelection = BumperSelection.WeightedRandom,
BumperFont = BumperFont.Sans,
LogoOpacity = 0.8,
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
DayStartTime = DefaultDayStartTime,
CreatedAt = DateTimeOffset.UtcNow,
};
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
return channel;
}
public void UpdateSettings(
string name,
bool isEnabled,
bool bumpersEnabled,
Guid? fillerAssetId
)
{
Name = name;
IsEnabled = isEnabled;
BumpersEnabled = bumpersEnabled;
FillerAssetId = fillerAssetId;
}
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
{
BumperFont = font;
BumperSelection = selection;
}
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
public void UpdateViewerSettings(
Guid? logoImageId,
LogoCorner logoCorner,
double logoOpacity,
bool showClock,
double analogFilterStrength
)
{
LogoImageId = logoImageId;
LogoCorner = logoCorner;
LogoOpacity = Math.Clamp(logoOpacity, 0.0, 1.0);
ShowClock = showClock;
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
}
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
public BumperTemplate AddBumperTemplate(string name)
{
var nextPosition =
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
var template = BumperTemplate.Create(Id, nextPosition, name);
_bumperTemplates.Add(template);
return template;
}
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
public bool RemoveBumperTemplate(Guid templateId)
{
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
if (template is null || template.IsDefault)
return false;
_bumperTemplates.Remove(template);
return true;
}
/// <summary>Привязать активный шаблон сетки.</summary>
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
/// <summary>
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
/// </summary>
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
{
Number = number is > 0 ? number : null;
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
DayStartTime = dayStartTime;
}
}
@@ -1,123 +1,123 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary>
public class ScheduleEntry
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
public Guid? SlotId { get; private set; }
/// <summary>
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
/// </summary>
public Guid? CollectionId { 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,
ScheduleEntryOrigin origin
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = origin.ShowId,
EpisodeIndex = origin.EpisodeIndex,
SlotId = origin.SlotId,
TraceJson = origin.TraceJson,
CollectionId = origin.CollectionId,
};
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid showId,
int episodeIndex
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
};
public static ScheduleEntry Ad(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
public static ScheduleEntry Bumper(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
Guid? bumperVariantId
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Bumper,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
BumperVariantId = bumperVariantId,
};
}
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary>
public class ScheduleEntry
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
public Guid? SlotId { get; private set; }
/// <summary>
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
/// </summary>
public Guid? CollectionId { 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,
ScheduleEntryOrigin origin
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = origin.ShowId,
EpisodeIndex = origin.EpisodeIndex,
SlotId = origin.SlotId,
TraceJson = origin.TraceJson,
CollectionId = origin.CollectionId,
};
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid showId,
int episodeIndex
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
};
public static ScheduleEntry Ad(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
public static ScheduleEntry Bumper(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
Guid? bumperVariantId
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Bumper,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
BumperVariantId = bumperVariantId,
};
}
+176 -176
View File
@@ -1,176 +1,176 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
/// </summary>
public class Show
{
private readonly List<ShowEpisode> _episodes = [];
private readonly List<ShowGenre> _genres = [];
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; }
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
/// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
/// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
public ShowAudience? Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ──
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
public string? MetadataProvider { get; private set; }
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
public string? MetadataExternalId { get; private set; }
public int? Year { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
string name,
ShowKind kind,
string? description = null,
string? originalName = null,
ShowAudience? audience = null
) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
OriginalName = Normalize(originalName),
Kind = kind,
Description = description,
Audience = audience,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</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;
Description = description;
}
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
public void SetName(string name) => Name = name;
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId)
{
if (!CanAddEpisode)
throw new InvalidOperationException(
"Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
_episodes.Add(episode);
return episode;
}
public bool RemoveEpisode(Guid episodeId)
{
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode is null)
return false;
_episodes.Remove(episode);
return true;
}
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
string provider,
string externalId,
string? description,
int? year,
Guid? posterImageId
)
{
MetadataProvider = provider;
MetadataExternalId = externalId;
if (!string.IsNullOrWhiteSpace(description))
Description = description;
Year = year;
if (posterImageId is not null)
PosterImageId = posterImageId;
}
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
public void UpdateMetadataManual(string? description, int? year)
{
MetadataProvider = "manual";
MetadataExternalId = null;
Description = description;
Year = year;
}
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata()
{
MetadataProvider = null;
MetadataExternalId = null;
Year = null;
PosterImageId = null;
Description = null;
}
}
namespace TeleWave.Domain.Library;
/// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
/// </summary>
public class Show
{
private readonly List<ShowEpisode> _episodes = [];
private readonly List<ShowGenre> _genres = [];
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; }
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
/// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
/// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
public ShowAudience? Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ──
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
public string? MetadataProvider { get; private set; }
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
public string? MetadataExternalId { get; private set; }
public int? Year { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
string name,
ShowKind kind,
string? description = null,
string? originalName = null,
ShowAudience? audience = null
) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
OriginalName = Normalize(originalName),
Kind = kind,
Description = description,
Audience = audience,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</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;
Description = description;
}
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
public void SetName(string name) => Name = name;
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId)
{
if (!CanAddEpisode)
throw new InvalidOperationException(
"Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
_episodes.Add(episode);
return episode;
}
public bool RemoveEpisode(Guid episodeId)
{
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode is null)
return false;
_episodes.Remove(episode);
return true;
}
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
string provider,
string externalId,
string? description,
int? year,
Guid? posterImageId
)
{
MetadataProvider = provider;
MetadataExternalId = externalId;
if (!string.IsNullOrWhiteSpace(description))
Description = description;
Year = year;
if (posterImageId is not null)
PosterImageId = posterImageId;
}
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
public void UpdateMetadataManual(string? description, int? year)
{
MetadataProvider = "manual";
MetadataExternalId = null;
Description = description;
Year = year;
}
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata()
{
MetadataProvider = null;
MetadataExternalId = null;
Year = null;
PosterImageId = null;
Description = null;
}
}
@@ -1,21 +1,21 @@
namespace TeleWave.Domain.Media;
/// <summary>Откуда файл попал в хранилище.</summary>
public enum MediaSource
{
/// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary>
Upload,
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
Inbox,
/// <summary>
/// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит
/// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу.
/// </summary>
ManualInbox = 3,
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
Generated,
}
namespace TeleWave.Domain.Media;
/// <summary>Откуда файл попал в хранилище.</summary>
public enum MediaSource
{
/// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary>
Upload,
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
Inbox,
/// <summary>
/// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит
/// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу.
/// </summary>
ManualInbox = 3,
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
Generated,
}
@@ -1,221 +1,221 @@
using TeleWave.Domain.Library;
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>
ShowAudience? Audience = null,
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
);
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
public sealed record RepeatLimit(int WindowDays, int Max);
/// <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,
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
ShowAudience? MaxAudience = null,
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
RepeatLimit? RepeatLimit = 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,
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
Guid? CollectionId = 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,
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
CandidatesFiltered = 5,
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
/// <summary>Врезок в часе больше заданного потолка.</summary>
BreakLimitExceeded = 6,
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
GenreShareExceeded = 7,
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
FallbackShareExceeded = 8,
}
using TeleWave.Domain.Library;
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>
ShowAudience? Audience = null,
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
);
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
public sealed record RepeatLimit(int WindowDays, int Max);
/// <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,
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
ShowAudience? MaxAudience = null,
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
RepeatLimit? RepeatLimit = 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,
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
Guid? CollectionId = 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,
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
CandidatesFiltered = 5,
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
/// <summary>Врезок в часе больше заданного потолка.</summary>
BreakLimitExceeded = 6,
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
GenreShareExceeded = 7,
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
FallbackShareExceeded = 8,
}
@@ -1,465 +1,465 @@
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;
/// <summary>
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
///
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
/// границу слота.
/// </summary>
private sealed class PlanningRun(PlanningInput input, IRandomSource random)
{
public PlanningInput Input { get; } = input;
public IRandomSource Random { get; } = random;
public List<PlannedItem> Items { get; } = [];
public List<PlanningCursorUpdate> Cursors { get; } = [];
public List<PlanningWarning> Warnings { get; } = [];
public JunctionHistory Junctions { get; } = new();
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
public Guid? PreviousShowId { get; set; }
}
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
{
var run = new PlanningRun(input, random);
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
var cursor = input.StartUtc;
var iterations = 0;
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, run, out var trace);
if (cursor >= input.HorizonEndUtc)
break;
cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
}
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
// упрётся в дыру.
if (cursor < input.HorizonEndUtc)
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.FallbackEmpty,
null,
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
)
);
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
}
/// <summary>
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
/// фактический старт и заполняет трейс сведениями о дрейфе.
/// </summary>
private static DateTimeOffset OpenSlot(
PlanningSlot slot,
DateTimeOffset cursor,
PlanningRun run,
out PlanTrace trace
)
{
var snapped = false;
if (cursor < slot.TargetStartUtc)
{
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, 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, run, slot.SlotId);
snapped = afterFill > cursor;
cursor = afterFill;
}
}
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
if (Math.Abs(drift) > slot.MaxDriftMinutes)
run.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,
PlanTrace trace,
PlanningRun run
)
{
var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
switch (slot.SlotKind)
{
case SlotKind.SignOff:
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
return FillWithFallback(
cursor,
Min(slot.TargetEndUtc, limit),
run,
slot.SlotId,
PlannedItemKind.SignOff,
trace
);
case SlotKind.Repeat:
return FillRepeat(slot, cursor, limit, trace, run);
default:
return FillContent(slot, cursor, limit, trace, run);
}
}
private static DateTimeOffset FillRepeat(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanTrace trace,
PlanningRun run
)
{
var units = slot.RepeatUnits ?? [];
if (units.Count == 0)
{
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.RepeatSourceEmpty,
slot.SlotId,
"В источнике повтора ничего не нашлось — слот закрыт фоном."
)
);
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
var slotEnd = Min(slot.TargetEndUtc, limit);
foreach (var unit in units)
{
if (cursor + unit.Duration > slotEnd)
break;
run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
cursor += unit.Duration;
}
return cursor;
}
private static DateTimeOffset FillContent(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanTrace trace,
PlanningRun run
)
{
var pick = ElementSelector.Select(slot, cursor, run.Random);
if (pick is null)
{
run.Warnings.Add(NoCandidatesWarning(slot));
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
if (pick.CooldownExhausted)
run.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)
)
{
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
}
while (unitIndex < element.Units.Count && cursor < limit)
{
var unit = element.Units[unitIndex];
// Врезки между единицами: перед каждой, кроме первой в блоке.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionBetween,
cursor,
limit,
new JunctionPlacement(
slot.SlotId,
run.PreviousShowId,
unit.ShowId,
ElementChanged: run.PreviousShowId != unit.ShowId
),
run.Junctions,
run.Items,
slotTrace
);
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
if (cursor + unit.Duration > limit)
break;
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break;
run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
cursor += unit.Duration;
accumulated += unit.Duration;
unitIndex++;
placed++;
run.PreviousShowId = unit.ShowId;
}
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionAfter,
cursor,
limit,
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
run.Junctions,
run.Items,
slotTrace
);
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
return cursor;
}
/// <summary>
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
/// группы.
/// </summary>
private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
{
var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
var hasAllowed = slot.Elements.Any(e =>
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
);
return hasPlayable && !hasAllowed
? new PlanningWarning(
PlanningWarningKind.CandidatesFiltered,
slot.SlotId,
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
)
: new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
);
}
/// <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,
PlanningRun run,
Guid? slotId,
PlannedItemKind kind = PlannedItemKind.Fallback,
PlanTrace? trace = null
)
{
var fallback = run.Input.FallbackUnits;
if (fallback.Count == 0 || until <= from)
return from;
var cursor = from;
var index = 0;
var guard = 0;
while (cursor < until && guard++ < IterationBackstop)
{
var unit = fallback[index % fallback.Count];
index++;
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
break;
run.Items.Add(
new PlannedItem(
unit.MediaAssetId,
cursor,
cursor + unit.Duration,
null,
null,
slotId,
kind,
trace
)
);
cursor += unit.Duration;
}
return cursor;
}
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
private static PlannedItem Program(
PlanningUnit unit,
DateTimeOffset start,
Guid slotId,
PlanTrace trace,
Guid? collectionId = null
) =>
new(
unit.MediaAssetId,
start,
start + unit.Duration,
unit.ShowId,
unit.UnitIndex,
slotId,
PlannedItemKind.Program,
trace,
CollectionId: collectionId
);
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);
}
}
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;
/// <summary>
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
///
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
/// границу слота.
/// </summary>
private sealed class PlanningRun(PlanningInput input, IRandomSource random)
{
public PlanningInput Input { get; } = input;
public IRandomSource Random { get; } = random;
public List<PlannedItem> Items { get; } = [];
public List<PlanningCursorUpdate> Cursors { get; } = [];
public List<PlanningWarning> Warnings { get; } = [];
public JunctionHistory Junctions { get; } = new();
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
public Guid? PreviousShowId { get; set; }
}
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
{
var run = new PlanningRun(input, random);
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
var cursor = input.StartUtc;
var iterations = 0;
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, run, out var trace);
if (cursor >= input.HorizonEndUtc)
break;
cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
}
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
// упрётся в дыру.
if (cursor < input.HorizonEndUtc)
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.FallbackEmpty,
null,
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
)
);
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
}
/// <summary>
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
/// фактический старт и заполняет трейс сведениями о дрейфе.
/// </summary>
private static DateTimeOffset OpenSlot(
PlanningSlot slot,
DateTimeOffset cursor,
PlanningRun run,
out PlanTrace trace
)
{
var snapped = false;
if (cursor < slot.TargetStartUtc)
{
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, 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, run, slot.SlotId);
snapped = afterFill > cursor;
cursor = afterFill;
}
}
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
if (Math.Abs(drift) > slot.MaxDriftMinutes)
run.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,
PlanTrace trace,
PlanningRun run
)
{
var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
switch (slot.SlotKind)
{
case SlotKind.SignOff:
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
return FillWithFallback(
cursor,
Min(slot.TargetEndUtc, limit),
run,
slot.SlotId,
PlannedItemKind.SignOff,
trace
);
case SlotKind.Repeat:
return FillRepeat(slot, cursor, limit, trace, run);
default:
return FillContent(slot, cursor, limit, trace, run);
}
}
private static DateTimeOffset FillRepeat(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanTrace trace,
PlanningRun run
)
{
var units = slot.RepeatUnits ?? [];
if (units.Count == 0)
{
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.RepeatSourceEmpty,
slot.SlotId,
"В источнике повтора ничего не нашлось — слот закрыт фоном."
)
);
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
var slotEnd = Min(slot.TargetEndUtc, limit);
foreach (var unit in units)
{
if (cursor + unit.Duration > slotEnd)
break;
run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
cursor += unit.Duration;
}
return cursor;
}
private static DateTimeOffset FillContent(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanTrace trace,
PlanningRun run
)
{
var pick = ElementSelector.Select(slot, cursor, run.Random);
if (pick is null)
{
run.Warnings.Add(NoCandidatesWarning(slot));
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
if (pick.CooldownExhausted)
run.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)
)
{
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
}
while (unitIndex < element.Units.Count && cursor < limit)
{
var unit = element.Units[unitIndex];
// Врезки между единицами: перед каждой, кроме первой в блоке.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionBetween,
cursor,
limit,
new JunctionPlacement(
slot.SlotId,
run.PreviousShowId,
unit.ShowId,
ElementChanged: run.PreviousShowId != unit.ShowId
),
run.Junctions,
run.Items,
slotTrace
);
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
if (cursor + unit.Duration > limit)
break;
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break;
run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
cursor += unit.Duration;
accumulated += unit.Duration;
unitIndex++;
placed++;
run.PreviousShowId = unit.ShowId;
}
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionAfter,
cursor,
limit,
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
run.Junctions,
run.Items,
slotTrace
);
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
return cursor;
}
/// <summary>
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
/// группы.
/// </summary>
private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
{
var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
var hasAllowed = slot.Elements.Any(e =>
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
);
return hasPlayable && !hasAllowed
? new PlanningWarning(
PlanningWarningKind.CandidatesFiltered,
slot.SlotId,
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
)
: new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
);
}
/// <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,
PlanningRun run,
Guid? slotId,
PlannedItemKind kind = PlannedItemKind.Fallback,
PlanTrace? trace = null
)
{
var fallback = run.Input.FallbackUnits;
if (fallback.Count == 0 || until <= from)
return from;
var cursor = from;
var index = 0;
var guard = 0;
while (cursor < until && guard++ < IterationBackstop)
{
var unit = fallback[index % fallback.Count];
index++;
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
break;
run.Items.Add(
new PlannedItem(
unit.MediaAssetId,
cursor,
cursor + unit.Duration,
null,
null,
slotId,
kind,
trace
)
);
cursor += unit.Duration;
}
return cursor;
}
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
private static PlannedItem Program(
PlanningUnit unit,
DateTimeOffset start,
Guid slotId,
PlanTrace trace,
Guid? collectionId = null
) =>
new(
unit.MediaAssetId,
start,
start + unit.Duration,
unit.ShowId,
unit.UnitIndex,
slotId,
PlannedItemKind.Program,
trace,
CollectionId: collectionId
);
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);
}
}
@@ -1,110 +1,110 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
/// для одного и того же. На другой канал переносится глубокой копией.
///
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
/// шаблон изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public class ScheduleTemplate
{
private readonly List<GridLayer> _layers = [];
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>
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
/// </summary>
public string? RulesJson { 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;
public void SetRules(string? rulesJson) =>
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
/// <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);
}
namespace TeleWave.Domain.Programming;
/// <summary>
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
/// для одного и того же. На другой канал переносится глубокой копией.
///
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
/// шаблон изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public class ScheduleTemplate
{
private readonly List<GridLayer> _layers = [];
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>
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
/// </summary>
public string? RulesJson { 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;
public void SetRules(string? rulesJson) =>
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
/// <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);
}
+131 -131
View File
@@ -1,131 +1,131 @@
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(SlotContent content)
{
JunctionBetweenId = content.JunctionBetweenId;
JunctionAfterId = content.JunctionAfterId;
Title = content.Title.Trim();
SlotKind = content.SlotKind;
BlockMode = content.BlockMode;
BlockValue = Math.Max(1, content.BlockValue);
OverflowPolicy = content.OverflowPolicy;
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
}
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
public TimeSpan TargetEndOffset =>
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
}
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(SlotContent content)
{
JunctionBetweenId = content.JunctionBetweenId;
JunctionAfterId = content.JunctionAfterId;
Title = content.Title.Trim();
SlotKind = content.SlotKind;
BlockMode = content.BlockMode;
BlockValue = Math.Max(1, content.BlockValue);
OverflowPolicy = content.OverflowPolicy;
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
}
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
public TimeSpan TargetEndOffset =>
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
}