namespace TeleWave.Domain.Broadcast.Scheduling; /// /// Чистая эфирная математика: разворачивает конфигурацию канала в последовательность записей встык /// от до . Без БД, ФС и /// ffmpeg — полностью юнит-тестируемо (см. SchedulePlannerTests). /// /// Инварианты: серии одного шоу идут по порядку (курсор ), /// на конце сериала — заворот на первую серию; выбор шоу — взвешенно-случайный; override на окне /// заменяет базовую ротацию; реклама вставляется по политике канала. /// public static class SchedulePlanner { private const int IterationBackstop = 1_000_000; public static PlannerResult Plan(PlannerInput input, IRandomSource random) { var entries = new List(); 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); } /// /// Ставит на переходе заставку выбранного подблока: из подходящих по правилу показа (контекст — /// сменилось ли шоу) выбирает один по стратегии канала и резервирует слот длины его блока. Оставляет /// плейсхолдер с парой шоу + id блока/варианта (ассет отрендерит оркестратор). Возвращает true, если /// заставка добавлена (курсор сдвинут). /// private static bool TryPlaceBumper( List 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, }; /// Прошла ли проверка вероятности появления (chance 0..1). 1 — всегда, 0 — никогда. 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); } /// Взвешенный случайный выбор подблока по (нулевые веса → равновероятно). private static PlannerBumperVariant WeightedPickVariant( List 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 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(); } /// Вес шоу с учётом предпочтительных часов: в окне — усиливается множителем, иначе базовый. 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 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 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; }