Enhance template and scheduling functionalities: add PlanningRules to template endpoints and commands, implement repeat limits and audience filtering in scheduling logic, and update related data structures. Refactor frontend components to support new rules and improve user experience in channel management.

This commit is contained in:
Leonid Pershin
2026-07-26 14:25:32 +03:00
parent 7b12a06d1b
commit 8494eb5e6a
30 changed files with 3400 additions and 438 deletions
@@ -22,7 +22,11 @@ public static class ElementSelector
IRandomSource random
)
{
var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList();
// Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора:
// начатый в общее время сериал не должен доигрываться в детское.
var playable = slot
.Elements.Where(e => e.Units.Count > 0 && IsAllowedByAudience(slot, e))
.ToList();
if (playable.Count == 0)
return null;
@@ -95,10 +99,14 @@ public static class ElementSelector
if (current is not null && HasUnitsLeft(slot, current))
return Continue(slot, current);
// Потолок повторов — тоже жёсткий фильтр, но применяется только здесь: в последовательной
// стратегии он выбрасывал бы очередную серию сериала и рвал порядок показа.
var withinLimit = ApplyRepeatLimit(slot, playable, moment);
var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
var eligible = cooldown <= TimeSpan.Zero
? playable
: playable
? withinLimit
: withinLimit
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
.ToList();
@@ -106,16 +114,47 @@ public static class ElementSelector
if (exhausted)
{
// Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
// пустой эфир хуже раннего повтора в обоих случаях.
// пустой эфир хуже раннего повтора в обоих случаях. Откат идёт по уже суженному потолком
// повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим.
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
? playable
: [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
? withinLimit
: [withinLimit.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
}
var picked = WeightedPick(eligible, random);
return new ElementPick(picked, 0, eligible.Count, exhausted);
}
/// <summary>
/// Проходит ли элемент по возрастному потолку слота. Категории упорядочены по строгости, поэтому
/// сравнение — обычное «не строже». Элемент без категории не отсекается: неизвестное не значит
/// «взрослое», а молчаливое выбрасывание такого контента искало бы потом весь вечер.
/// </summary>
public static bool IsAllowedByAudience(PlanningSlot slot, PlanningElement element) =>
slot.MaxAudience is not { } max || element.Audience is not { } audience || audience <= max;
/// <summary>
/// Отсекает элементы, выбранные за период чаще потолка. История — старты уже записанных показов;
/// без неё (правило не задано или лента не загружена) фильтр не работает и никого не выбрасывает.
/// </summary>
private static List<PlanningElement> ApplyRepeatLimit(
PlanningSlot slot,
List<PlanningElement> playable,
DateTimeOffset moment
)
{
if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0)
return playable;
var from = moment.AddDays(-limit.WindowDays);
var filtered = playable
.Where(e => (e.RecentPlaysUtc?.Count(p => p >= from) ?? 0) < limit.Max)
.ToList();
// Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание.
return filtered.Count == 0 ? playable : filtered;
}
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
{
@@ -1,3 +1,5 @@
using TeleWave.Domain.Library;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
@@ -16,9 +18,16 @@ public sealed record PlanningElement(
int Weight,
int Position,
IReadOnlyList<PlanningUnit> Units,
DateTimeOffset? LastPlayedUtc = null
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,
@@ -66,7 +75,11 @@ public sealed record PlanningSlot(
/// <summary>Врезки между единицами внутри блока.</summary>
PlanningJunction? JunctionBetween = null,
/// <summary>Врезки в конце блока.</summary>
PlanningJunction? JunctionAfter = null
PlanningJunction? JunctionAfter = null,
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
ShowAudience? MaxAudience = null,
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
RepeatLimit? RepeatLimit = null
)
{
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
@@ -189,4 +202,7 @@ public enum PlanningWarningKind
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
FallbackEmpty = 4,
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
CandidatesFiltered = 5,
}
@@ -244,12 +244,26 @@ public static class SchedulePlanner
var pick = ElementSelector.Select(slot, cursor, random);
if (pick is null)
{
// Пустая группа и отсечённая фильтром — разные беды: во втором случае контент есть,
// но не подходит по правилам, и админу надо чинить правило, а не состав группы.
var filteredOut =
slot.Elements.Any(e => e.Units.Count > 0)
&& !slot.Elements.Any(e =>
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
);
warnings.Add(
new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
)
filteredOut
? new PlanningWarning(
PlanningWarningKind.CandidatesFiltered,
slot.SlotId,
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
)
: new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
)
);
return FillWithFallback(
cursor,
@@ -22,6 +22,12 @@ public class ScheduleTemplate
/// <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; }
@@ -63,6 +69,9 @@ public class ScheduleTemplate
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
public void SetRules(string? rulesJson) =>
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
public void MarkChanged() => Revision++;