using TeleWave.Domain.Library; namespace TeleWave.Domain.Programming.Planning; /// /// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не /// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям. /// public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex); /// /// Элемент группы, развёрнутый в последовательность единиц. — когда он /// в последний раз выходил в этом канале; по нему работает остывание. /// public sealed record PlanningElement( GroupElementKind Kind, Guid ElementId, int Weight, int Position, IReadOnlyList Units, DateTimeOffset? LastPlayedUtc = null, /// Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время. ShowAudience? Audience = null, /// Старты недавних показов в этом канале — по ним считается потолок повторов за период. IReadOnlyList? RecentPlaysUtc = null ); /// Потолок повторов: не чаще раз за суток. public sealed record RepeatLimit(int WindowDays, int Max); /// Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику. public sealed record PlanningStrategy( SlotStrategyKind Kind, bool RestartOnEnd = true, int CooldownDays = 0, bool IgnoreCooldownWhenExhausted = false, Guid? FixedElementId = null ); /// Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application. public enum SlotStrategyKind { Sequential = 0, RandomWithCooldown = 1, Fixed = 2, } /// Где остановились в текущем элементе на момент начала прогона. public sealed record PlanningCursor( GroupElementKind? ElementKind, Guid? ElementId, int NextUnitIndex ); /// /// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают /// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC. /// 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 Elements, PlanningCursor? Cursor, /// Готовые записи для — что играло в источнике повтора. IReadOnlyList? RepeatUnits = null, /// Врезки между единицами внутри блока. PlanningJunction? JunctionBetween = null, /// Врезки в конце блока. PlanningJunction? JunctionAfter = null, /// Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр. ShowAudience? MaxAudience = null, /// Потолок повторов за период (null — без ограничения). Жёсткий фильтр. RepeatLimit? RepeatLimit = null ) { public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes); } /// Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00). public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To) { public bool Contains(TimeOnly moment) => From <= To ? moment >= From && moment < To : moment >= From || moment < To; } /// /// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся /// решить, сколько их поставить и влезают ли они. /// public sealed record PlanningJunctionElement( Guid ElementId, JunctionElementKind Kind, IReadOnlyList Units, JunctionAmountMode AmountMode, int AmountValue, bool IsRequired, /// Ставить только при смене элемента (иначе — и между единицами одного). bool OnlyOnElementChange = false, /// Не ставить чаще, чем раз в N минут (0 — без ограничения). int MinMinutesBetween = 0, /// Вероятность показа в процентах (100 — всегда). int Chance = 100, /// Окно времени суток, вне которого врезка не ставится (null — всегда). PlanningTimeWindow? TimeWindow = null, /// Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам. string? ChoiceKey = null, int ChoiceWeight = 1, /// Блок заставки — ассет рендерится позже, планировщик резервирует длительность. Guid? BumperTemplateId = null, /// Конкретный подблок заставки; null — выберет резолвер по триггеру и весам. Guid? BumperVariantId = null, /// Длительность резерва под заставку. TimeSpan BumperDuration = default ); /// Стык: последовательность врезок между программами. public sealed record PlanningJunction( Guid JunctionId, IReadOnlyList Elements, /// Потолок длины стыка целиком (null — ограничен только якорем). TimeSpan? MaxTotal = null ); /// Полный вход одного прогона генератора. public sealed record PlanningInput( Guid ChannelId, DateTimeOffset StartUtc, DateTimeOffset HorizonEndUtc, IReadOnlyList Slots, /// Чем закрывать место, не покрытое слотами и не заполненное контентом. IReadOnlyList FallbackUnits, int SegmentSeconds, /// Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка. int UtcOffsetMinutes = 0 ); /// Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно. public sealed record PlannedItem( Guid MediaAssetId, DateTimeOffset StartsAtUtc, DateTimeOffset EndsAtUtc, Guid? ShowId, int? UnitIndex, Guid? SlotId, PlannedItemKind Kind, PlanTrace? Trace = null, /// Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже. Guid? BumperTemplateId = null, Guid? FromShowId = null, Guid? ToShowId = null, /// Подблок заставки, если врезка задала его жёстко; null — выберет резолвер. Guid? BumperVariantId = null, /// Коллекция, частью которой шла единица (null — шоу играло само по себе). Guid? CollectionId = null ); public enum PlannedItemKind { Program = 0, Fallback = 1, SignOff = 2, Ad = 3, Promo = 4, /// /// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента /// собрана, — планировщик лишь резервирует под неё длительность. /// Bumper = 5, } /// Цепочка происхождения записи — питает экран «почему это здесь». public sealed record PlanTrace( Guid? SlotId, SlotKind SlotKind, GroupElementKind? ElementKind, Guid? ElementId, SlotStrategyKind? Strategy, /// Сколько кандидатов осталось после остывания (null — выбор без остывания). int? CandidatesAfterCooldown, /// Насколько фактический старт разошёлся с целевым, минуты. int DriftMinutes, /// Старт сдвинут вперёд округлением до круглого времени. bool Snapped ); /// Новое состояние слота после прогона — оркестратор сохраняет его в БД. public sealed record PlanningCursorUpdate( Guid SlotId, GroupElementKind? ElementKind, Guid? ElementId, int NextUnitIndex ); /// Результат прогона: лента, новые курсоры и предупреждения для админа. public sealed record PlanningResult( IReadOnlyList Items, IReadOnlyList Cursors, IReadOnlyList Warnings ); /// Предупреждение по результату генерации: не ошибка, но админу это видеть нужно. public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details); public enum PlanningWarningKind { /// Слот не дал контента — место закрыл фон. SlotEmpty = 0, /// Фактический старт ушёл дальше допуска. DriftExceeded = 1, /// Остывание отсекло всех кандидатов. CooldownExhausted = 2, /// Не нашлось, что повторить. RepeatSourceEmpty = 3, /// Пусто даже в фоне — в ленте образуется дыра. FallbackEmpty = 4, /// Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата. CandidatesFiltered = 5, // ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ── /// Врезок в часе больше заданного потолка. BreakLimitExceeded = 6, /// Доля одного жанра за сутки выше заданной. GenreShareExceeded = 7, /// Фон занял больше эфира, чем считается нормой. FallbackShareExceeded = 8, }