Enhance channel management: add support for preferred weight multipliers and preferred hours in channel shows, update related data models and API endpoints, and implement validation for new properties. Refactor scheduling logic to utilize preferred hours for weight adjustments during show planning.

This commit is contained in:
Leonid Pershin
2026-07-25 18:44:28 +03:00
parent 358adbff21
commit 8718e8b6bf
36 changed files with 2921 additions and 97 deletions
@@ -6,9 +6,12 @@ public enum BumperSelection
/// <summary>По кругу в порядке блоков (курсор <see cref="Channel.NextBumperIndex"/>).</summary>
Rotation,
/// <summary>Случайный блок на каждом переходе.</summary>
/// <summary>Случайный блок на каждом переходе (равновероятно).</summary>
Random,
/// <summary>Всегда первый (дефолтный) блок.</summary>
AlwaysFirst,
/// <summary>Случайный блок с учётом веса подблока (<see cref="BumperTextVariant.Weight"/>).</summary>
WeightedRandom,
}
@@ -24,8 +24,13 @@ public class BumperTextVariant
public BumperTrigger Trigger { get; private set; }
/// <summary>Вес при стратегии <see cref="BumperSelection.WeightedRandom"/> (0 — не выбирается). Иначе игнорируется.</summary>
public int Weight { get; private set; } = DefaultWeight;
public DateTimeOffset CreatedAt { get; private set; }
public const int DefaultWeight = 1;
public const string DefaultNowLabel = "СЕЙЧАС";
public const string DefaultNextLabel = "ДАЛЕЕ";
@@ -49,6 +54,7 @@ public class BumperTextVariant
Line1 = string.Empty,
Line2 = string.Empty,
Trigger = trigger,
Weight = DefaultWeight,
CreatedAt = DateTimeOffset.UtcNow,
};
@@ -59,7 +65,8 @@ public class BumperTextVariant
string nextLabel,
string line1,
string line2,
BumperTrigger trigger
BumperTrigger trigger,
int weight
)
{
Name = name;
@@ -69,6 +76,7 @@ public class BumperTextVariant
Line1 = line1;
Line2 = line2;
Trigger = trigger;
Weight = Math.Max(0, weight);
}
/// <summary>Подходит ли подблок для перехода: <paramref name="isShowChange"/> — сменилось ли шоу.</summary>
@@ -39,6 +39,12 @@ public class Channel
/// <summary>Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе).</summary>
public int BumperMinIntervalMinutes { get; private set; }
/// <summary>Вероятность заставки на смене шоу (0..1; 1 — на каждой смене, 0 — никогда).</summary>
public double BumperShowChangeChance { get; private set; } = 1.0;
/// <summary>Вероятность заставки между блоками одного шоу (0..1; напр. 0.3 — примерно в 30% случаев).</summary>
public double BumperEpisodeChangeChance { get; private set; } = 1.0;
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
@@ -76,6 +82,8 @@ public class Channel
NextBumperIndex = 0,
BumperFont = BumperFont.Sans,
BumperMinIntervalMinutes = 0,
BumperShowChangeChance = 1.0,
BumperEpisodeChangeChance = 1.0,
NextAdIndex = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
@@ -101,16 +109,23 @@ public class Channel
FillerAssetId = fillerAssetId;
}
/// <summary>Общие настройки ТВ-заставок канала: шрифт, мин. интервал и стратегия выбора подблока.</summary>
/// <summary>
/// Общие настройки ТВ-заставок канала: шрифт, мин. интервал, стратегия выбора подблока и
/// вероятности появления на смене шоу / между блоками одного шоу (0..1).
/// </summary>
public void UpdateBumperSettings(
BumperFont font,
int minIntervalMinutes,
BumperSelection selection
BumperSelection selection,
double showChangeChance,
double episodeChangeChance
)
{
BumperFont = font;
BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes);
BumperSelection = selection;
BumperShowChangeChance = Math.Clamp(showChangeChance, 0.0, 1.0);
BumperEpisodeChangeChance = Math.Clamp(episodeChangeChance, 0.0, 1.0);
}
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
@@ -7,6 +7,8 @@ namespace TeleWave.Domain.Broadcast;
/// </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; }
@@ -21,6 +23,14 @@ public class ChannelShow
/// <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(
@@ -40,6 +50,7 @@ public class ChannelShow
BlockValue = blockValue,
IsEnabled = true,
NextEpisodeIndex = 0,
PreferredWeightMultiplier = DefaultPreferredWeightMultiplier,
};
public void Update(int weight, BlockMode blockMode, int blockValue, bool isEnabled)
@@ -50,6 +61,25 @@ public class ChannelShow
IsEnabled = isEnabled;
}
/// <summary>
/// Задаёт множитель веса и полностью заменяет набор окон предпочтительных часов. Окна нормализуются:
/// отбрасываются некорректные ([0,24], start &lt; end), совпадающие схлопываются.
/// </summary>
public void SetPreferredHours(int multiplier, IEnumerable<(int StartHour, int EndHour)> windows)
{
PreferredWeightMultiplier = Math.Max(1, multiplier);
_preferredHours.Clear();
foreach (var (start, end) in windows
.Where(w => w.StartHour >= 0 && w.EndHour <= 24 && w.StartHour < w.EndHour)
.Distinct())
{
_preferredHours.Add(ChannelShowHour.Create(Id, start, end));
}
}
/// <summary>Час суток (0..23) попадает в одно из окон предпочтительных часов.</summary>
public bool IsPreferredAt(int hour) => _preferredHours.Any(w => w.Contains(hour));
/// <summary>Планировщик двигает курсор по мере постановки серий в расписание.</summary>
public void SetNextEpisodeIndex(int index) => NextEpisodeIndex = index;
}
@@ -0,0 +1,32 @@
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;
}
@@ -19,6 +19,9 @@ public class ScheduleEntry
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
private ScheduleEntry() { }
public static ScheduleEntry Program(
@@ -63,7 +66,8 @@ public class ScheduleEntry
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId
Guid? showId,
Guid? bumperVariantId
) =>
new()
{
@@ -74,5 +78,6 @@ public class ScheduleEntry
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
BumperVariantId = bumperVariantId,
};
}
@@ -40,7 +40,8 @@ public static class SchedulePlanner
var pick = WeightedPick(candidates, random);
// ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту)
// резервируем слот выбранного блока — ассет подставит оркестратор.
// резервируем слот выбранного блока — ассет подставит оркестратор. Само появление
// ограничено мин. интервалом и вероятностью для типа перехода (смена шоу / между блоками).
if (
prevShowId is { } prev
&& input.Bumpers is { Enabled: true } bumper
@@ -51,9 +52,14 @@ public static class SchedulePlanner
)
)
{
var bumperStart = cursor;
if (TryPlaceBumper(entries, bumper, prev, pick.ShowId, random, ref nextBumper, ref cursor))
lastBumperAt = bumperStart;
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;
@@ -121,6 +127,9 @@ public static class SchedulePlanner
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;
@@ -158,6 +167,38 @@ public static class SchedulePlanner
_ => 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 => Math.Max(0, v.Weight));
if (total <= 0)
return eligible[random.Next(eligible.Count)];
var roll = random.Next(total);
var 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,
@@ -182,12 +223,25 @@ public static class SchedulePlanner
// Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации.
}
var hour = moment.UtcDateTime.Hour;
return input.Shows
.Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0)
.Select(s => (s, s.Weight))
.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))
)
return show.Weight * show.PreferredWeightMultiplier;
return show.Weight;
}
private static PlannerShow WeightedPick(
List<(PlannerShow Show, int Weight)> candidates,
IRandomSource random
@@ -8,9 +8,17 @@ public sealed record PlannerShow(
BlockMode BlockMode,
int BlockValue,
IReadOnlyList<Guid> EpisodeAssetIds,
int NextEpisodeIndex
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 в терминах планировщика: окно + режим + шоу с весами.</summary>
public sealed record PlannerOverride(
DateTimeOffset StartsAtUtc,
@@ -25,23 +33,28 @@ public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
/// Политика ТВ-заставок на переходах. Планировщик из подходящих подблоков (<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
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
BumperTrigger Trigger,
int Weight = 1
);
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>