353 lines
13 KiB
C#
353 lines
13 KiB
C#
namespace TeleWave.Domain.Broadcast.Scheduling;
|
|
|
|
/// <summary>
|
|
/// Чистая эфирная математика: разворачивает конфигурацию канала в последовательность записей встык
|
|
/// от <see cref="PlannerInput.StartTime"/> до <see cref="PlannerInput.HorizonEnd"/>. Без БД, ФС и
|
|
/// ffmpeg — полностью юнит-тестируемо (см. SchedulePlannerTests).
|
|
///
|
|
/// Инварианты: серии одного шоу идут по порядку (курсор <see cref="PlannerShow.NextEpisodeIndex"/>),
|
|
/// на конце сериала — заворот на первую серию; выбор шоу — взвешенно-случайный; override на окне
|
|
/// заменяет базовую ротацию; реклама вставляется по политике канала.
|
|
/// </summary>
|
|
public static class SchedulePlanner
|
|
{
|
|
private const int IterationBackstop = 1_000_000;
|
|
|
|
public static PlannerResult Plan(PlannerInput input, IRandomSource random)
|
|
{
|
|
var entries = new List<PlannedEntry>();
|
|
var byShowId = input.Shows.ToDictionary(s => s.ShowId);
|
|
var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex);
|
|
var nextAd = input.NextAdIndex;
|
|
var nextBumper = input.NextBumperIndex;
|
|
|
|
// Есть ли вообще из чего строить эфир.
|
|
var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0);
|
|
if (!anyPlayable)
|
|
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
|
|
|
|
var cursor = input.StartTime;
|
|
var iterations = 0;
|
|
Guid? prevShowId = null;
|
|
DateTimeOffset? lastBumperAt = null;
|
|
|
|
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
|
|
{
|
|
var candidates = ResolvePolicy(cursor, input, byShowId);
|
|
if (candidates.Count == 0)
|
|
break;
|
|
|
|
var pick = WeightedPick(candidates, random);
|
|
|
|
// ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту)
|
|
// резервируем слот выбранного блока — ассет подставит оркестратор. Само появление
|
|
// ограничено мин. интервалом и вероятностью для типа перехода (смена шоу / между блоками).
|
|
if (
|
|
prevShowId is { } prev
|
|
&& input.Bumpers is { Enabled: true } bumper
|
|
&& (
|
|
bumper.MinInterval <= TimeSpan.Zero
|
|
|| lastBumperAt is not { } last
|
|
|| cursor - last >= bumper.MinInterval
|
|
)
|
|
)
|
|
{
|
|
var isShowChange = prev != pick.ShowId;
|
|
var chance = isShowChange ? bumper.ShowChangeChance : bumper.EpisodeChangeChance;
|
|
if (RollChance(chance, random))
|
|
{
|
|
var bumperStart = cursor;
|
|
if (
|
|
TryPlaceBumper(
|
|
entries,
|
|
bumper,
|
|
prev,
|
|
pick.ShowId,
|
|
random,
|
|
ref nextBumper,
|
|
ref cursor
|
|
)
|
|
)
|
|
lastBumperAt = bumperStart;
|
|
}
|
|
}
|
|
|
|
var blockStart = cursor;
|
|
|
|
var block = CollectBlock(pick, nextEpisode, input, cursor);
|
|
foreach (var episode in block)
|
|
{
|
|
var duration = DurationOf(episode.AssetId, input);
|
|
var end = cursor + duration;
|
|
entries.Add(
|
|
new PlannedEntry(
|
|
episode.AssetId,
|
|
ScheduleEntryKind.Program,
|
|
cursor,
|
|
end,
|
|
pick.ShowId,
|
|
episode.Index
|
|
)
|
|
);
|
|
cursor = end;
|
|
|
|
if (input.AdInsertion == AdInsertion.BetweenEpisodes)
|
|
cursor = InsertAds(entries, input, cursor, ref nextAd);
|
|
}
|
|
|
|
if (input.AdInsertion == AdInsertion.BetweenBlocks)
|
|
cursor = InsertAds(entries, input, cursor, ref nextAd);
|
|
|
|
// Защита от зацикливания, если длительности нулевые/отсутствуют — эфир не сдвинулся.
|
|
if (cursor <= blockStart)
|
|
break;
|
|
|
|
prevShowId = pick.ShowId;
|
|
}
|
|
|
|
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ставит на переходе заставку выбранного подблока: из подходящих по правилу показа (контекст —
|
|
/// сменилось ли шоу) выбирает один по стратегии канала и резервирует слот длины его блока. Оставляет
|
|
/// плейсхолдер с парой шоу + id блока/варианта (ассет отрендерит оркестратор). Возвращает true, если
|
|
/// заставка добавлена (курсор сдвинут).
|
|
/// </summary>
|
|
private static bool TryPlaceBumper(
|
|
List<PlannedEntry> entries,
|
|
PlannerBumperConfig bumper,
|
|
Guid fromShowId,
|
|
Guid toShowId,
|
|
IRandomSource random,
|
|
ref int nextBumper,
|
|
ref DateTimeOffset cursor
|
|
)
|
|
{
|
|
var isShowChange = fromShowId != toShowId;
|
|
var eligible = bumper
|
|
.Variants.Where(v =>
|
|
v.Duration > TimeSpan.Zero && MatchesTrigger(v.Trigger, isShowChange)
|
|
)
|
|
.ToList();
|
|
if (eligible.Count == 0)
|
|
return false;
|
|
|
|
PlannerBumperVariant variant;
|
|
switch (bumper.Selection)
|
|
{
|
|
case BumperSelection.Random:
|
|
variant = eligible[random.Next(eligible.Count)];
|
|
break;
|
|
case BumperSelection.WeightedRandom:
|
|
variant = WeightedPickVariant(eligible, random);
|
|
break;
|
|
case BumperSelection.AlwaysFirst:
|
|
variant = eligible[0];
|
|
break;
|
|
default: // Rotation
|
|
var idx = ((nextBumper % eligible.Count) + eligible.Count) % eligible.Count;
|
|
variant = eligible[idx];
|
|
nextBumper++;
|
|
break;
|
|
}
|
|
|
|
var end = cursor + variant.Duration;
|
|
entries.Add(
|
|
new PlannedEntry(
|
|
Guid.Empty,
|
|
ScheduleEntryKind.Bumper,
|
|
cursor,
|
|
end,
|
|
toShowId,
|
|
null,
|
|
fromShowId,
|
|
toShowId,
|
|
variant.TemplateId,
|
|
variant.VariantId
|
|
)
|
|
);
|
|
cursor = end;
|
|
return true;
|
|
}
|
|
|
|
private static bool MatchesTrigger(BumperTrigger trigger, bool isShowChange) =>
|
|
trigger switch
|
|
{
|
|
BumperTrigger.OnShowChange => isShowChange,
|
|
BumperTrigger.BetweenEpisodes => !isShowChange,
|
|
_ => true,
|
|
};
|
|
|
|
/// <summary>Прошла ли проверка вероятности появления (chance 0..1). 1 — всегда, 0 — никогда.</summary>
|
|
private static bool RollChance(double chance, IRandomSource random)
|
|
{
|
|
if (chance >= 1.0)
|
|
return true;
|
|
if (chance <= 0.0)
|
|
return false;
|
|
return random.Next(10000) < (int)Math.Round(chance * 10000);
|
|
}
|
|
|
|
/// <summary>Взвешенный случайный выбор подблока по <see cref="PlannerBumperVariant.Weight"/> (нулевые веса → равновероятно).</summary>
|
|
private static PlannerBumperVariant WeightedPickVariant(
|
|
List<PlannerBumperVariant> eligible,
|
|
IRandomSource random
|
|
)
|
|
{
|
|
var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
|
|
if (total <= 0)
|
|
return eligible[random.Next(eligible.Count)];
|
|
|
|
var roll = random.Next((int)Math.Min(total, int.MaxValue));
|
|
long acc = 0;
|
|
foreach (var v in eligible)
|
|
{
|
|
acc += Math.Max(0, v.Weight);
|
|
if (roll < acc)
|
|
return v;
|
|
}
|
|
|
|
return eligible[^1];
|
|
}
|
|
|
|
private static List<(PlannerShow Show, int Weight)> ResolvePolicy(
|
|
DateTimeOffset moment,
|
|
PlannerInput input,
|
|
IReadOnlyDictionary<Guid, PlannerShow> byShowId
|
|
)
|
|
{
|
|
var ovr = input.Overrides.FirstOrDefault(o => o.Covers(moment));
|
|
if (ovr is not null)
|
|
{
|
|
var overridden = new List<(PlannerShow, int)>();
|
|
foreach (var os in ovr.Shows)
|
|
{
|
|
if (
|
|
!byShowId.TryGetValue(os.ShowId, out var show)
|
|
|| show.EpisodeAssetIds.Count == 0
|
|
)
|
|
continue;
|
|
var weight = ovr.Mode == OverrideMode.Exclusive ? 1 : os.Weight;
|
|
if (weight > 0)
|
|
overridden.Add((show, weight));
|
|
}
|
|
|
|
if (overridden.Count > 0)
|
|
return overridden;
|
|
// Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации.
|
|
}
|
|
|
|
var hour = moment.UtcDateTime.Hour;
|
|
return input
|
|
.Shows.Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0)
|
|
.Select(s => (s, EffectiveWeight(s, hour)))
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>Вес шоу с учётом предпочтительных часов: в окне — усиливается множителем, иначе базовый.</summary>
|
|
private static int EffectiveWeight(PlannerShow show, int hour)
|
|
{
|
|
if (
|
|
show.PreferredWeightMultiplier > 1
|
|
&& show.PreferredHours is { Count: > 0 } windows
|
|
&& windows.Any(w => w.Contains(hour))
|
|
)
|
|
// long-умножение + clamp: экстремальные вес/множитель не переполняют int
|
|
// (иначе отрицательный total молча вырождает взвешенный выбор в первого кандидата).
|
|
return (int)Math.Min((long)show.Weight * show.PreferredWeightMultiplier, int.MaxValue);
|
|
return show.Weight;
|
|
}
|
|
|
|
private static PlannerShow WeightedPick(
|
|
List<(PlannerShow Show, int Weight)> candidates,
|
|
IRandomSource random
|
|
)
|
|
{
|
|
var total = candidates.Sum(c => (long)c.Weight);
|
|
if (total <= 0)
|
|
return candidates[0].Show;
|
|
|
|
var roll = random.Next((int)Math.Min(total, int.MaxValue));
|
|
long acc = 0;
|
|
foreach (var (show, weight) in candidates)
|
|
{
|
|
acc += weight;
|
|
if (roll < acc)
|
|
return show;
|
|
}
|
|
|
|
return candidates[^1].Show;
|
|
}
|
|
|
|
private static List<(Guid AssetId, int Index)> CollectBlock(
|
|
PlannerShow show,
|
|
Dictionary<Guid, int> nextEpisode,
|
|
PlannerInput input,
|
|
DateTimeOffset cursor
|
|
)
|
|
{
|
|
var result = new List<(Guid, int)>();
|
|
var count = show.EpisodeAssetIds.Count;
|
|
var idx = ((nextEpisode[show.ChannelShowId] % count) + count) % count;
|
|
|
|
if (show.BlockMode == BlockMode.Count)
|
|
{
|
|
var n = Math.Max(1, show.BlockValue);
|
|
for (var i = 0; i < n; i++)
|
|
{
|
|
result.Add((show.EpisodeAssetIds[idx], idx));
|
|
idx = (idx + 1) % count;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var budget = TimeSpan.FromMinutes(Math.Max(1, show.BlockValue));
|
|
var accumulated = TimeSpan.Zero;
|
|
var guard = 0;
|
|
do
|
|
{
|
|
var assetId = show.EpisodeAssetIds[idx];
|
|
result.Add((assetId, idx));
|
|
accumulated += DurationOf(assetId, input);
|
|
idx = (idx + 1) % count;
|
|
guard++;
|
|
} while (
|
|
accumulated < budget
|
|
&& cursor + accumulated < input.HorizonEnd
|
|
&& guard < IterationBackstop
|
|
);
|
|
}
|
|
|
|
nextEpisode[show.ChannelShowId] = idx;
|
|
return result;
|
|
}
|
|
|
|
private static DateTimeOffset InsertAds(
|
|
List<PlannedEntry> entries,
|
|
PlannerInput input,
|
|
DateTimeOffset cursor,
|
|
ref int nextAd
|
|
)
|
|
{
|
|
if (input.AdPool.Count == 0 || input.AdsPerBreak <= 0)
|
|
return cursor;
|
|
|
|
for (var i = 0; i < input.AdsPerBreak; i++)
|
|
{
|
|
var assetId = input.AdPool[
|
|
((nextAd % input.AdPool.Count) + input.AdPool.Count) % input.AdPool.Count
|
|
];
|
|
nextAd++;
|
|
var end = cursor + DurationOf(assetId, input);
|
|
entries.Add(new PlannedEntry(assetId, ScheduleEntryKind.Ad, cursor, end, null, null));
|
|
cursor = end;
|
|
}
|
|
|
|
return cursor;
|
|
}
|
|
|
|
private static TimeSpan DurationOf(Guid assetId, PlannerInput input) =>
|
|
input.Durations.TryGetValue(assetId, out var duration) ? duration : TimeSpan.Zero;
|
|
}
|