Implement BumperEndpoints and remove deprecated bumper-related functionality
Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
This commit is contained in:
@@ -29,6 +29,23 @@ public enum JunctionAmountMode
|
||||
Duration = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полный набор настроек врезки. Собран в запись, потому что по отдельности эти девять значений
|
||||
/// ехали бы через команду, хендлер и домен плоским списком, в котором ничего не читается.
|
||||
/// </summary>
|
||||
public sealed record JunctionElementSettings(
|
||||
JunctionElementKind Kind,
|
||||
Guid? GroupId,
|
||||
Guid? BumperTemplateId,
|
||||
Guid? BumperVariantId,
|
||||
JunctionAmountMode AmountMode,
|
||||
int AmountValue,
|
||||
bool IsRequired,
|
||||
string? ChoiceKey,
|
||||
int ChoiceWeight,
|
||||
string? ConditionsJson
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Врезка в шаблоне стыка. Условия показа хранятся структурно (<see cref="ConditionsJson"/>),
|
||||
/// а не выражением: парсер, его валидация и отдельный UI обошлись бы дорого, а покрывают ровно
|
||||
@@ -38,7 +55,10 @@ public class JunctionElement
|
||||
{
|
||||
public Guid Id { get; private set; }
|
||||
public Guid JunctionTemplateId { get; private set; }
|
||||
|
||||
/// <summary>Порядок показа в эфире — и только он: обязательность на порядок не влияет.</summary>
|
||||
public int Position { get; private set; }
|
||||
|
||||
public JunctionElementKind Kind { get; private set; }
|
||||
|
||||
/// <summary>Откуда брать единицы — для <see cref="JunctionElementKind.Ad"/>, <see cref="JunctionElementKind.Promo"/>, <see cref="JunctionElementKind.Filler"/>.</summary>
|
||||
@@ -47,6 +67,9 @@ public class JunctionElement
|
||||
/// <summary>Какой блок заставки рендерить — для <see cref="JunctionElementKind.Bumper"/>.</summary>
|
||||
public Guid? BumperTemplateId { get; private set; }
|
||||
|
||||
/// <summary>Конкретный подблок заставки; null — выбрать по триггеру перехода и весам.</summary>
|
||||
public Guid? BumperVariantId { get; private set; }
|
||||
|
||||
public JunctionAmountMode AmountMode { get; private set; }
|
||||
|
||||
/// <summary>Единиц либо минут.</summary>
|
||||
@@ -55,9 +78,20 @@ public class JunctionElement
|
||||
/// <summary>Обязательную врезку нельзя выбросить при нехватке времени.</summary>
|
||||
public bool IsRequired { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Метка развилки: из врезок с одной меткой в эфир идёт одна, выбранная по весам. Врезки одной
|
||||
/// развилки обязаны занимать непрерывный отрезок позиций — иначе неясно, куда встаёт выбранная.
|
||||
/// </summary>
|
||||
public string? ChoiceKey { get; private set; }
|
||||
|
||||
/// <summary>Вес внутри развилки (0 — не выбирается). Вне развилки не используется.</summary>
|
||||
public int ChoiceWeight { get; private set; }
|
||||
|
||||
/// <summary>Условия показа (JSON). Домен их не интерпретирует — схема живёт в Application.</summary>
|
||||
public string? ConditionsJson { get; private set; }
|
||||
|
||||
public const int DefaultChoiceWeight = 1;
|
||||
|
||||
private JunctionElement() { }
|
||||
|
||||
internal static JunctionElement Create(
|
||||
@@ -74,28 +108,36 @@ public class JunctionElement
|
||||
AmountMode = JunctionAmountMode.Count,
|
||||
AmountValue = 1,
|
||||
IsRequired = false,
|
||||
ChoiceWeight = DefaultChoiceWeight,
|
||||
};
|
||||
|
||||
public void Update(
|
||||
JunctionElementKind kind,
|
||||
Guid? groupId,
|
||||
Guid? bumperTemplateId,
|
||||
JunctionAmountMode amountMode,
|
||||
int amountValue,
|
||||
bool isRequired,
|
||||
string? conditionsJson
|
||||
)
|
||||
public void Update(JunctionElementSettings settings)
|
||||
{
|
||||
Kind = kind;
|
||||
AmountMode = amountMode;
|
||||
AmountValue = Math.Max(1, amountValue);
|
||||
IsRequired = isRequired;
|
||||
ConditionsJson = string.IsNullOrWhiteSpace(conditionsJson) ? null : conditionsJson;
|
||||
Kind = settings.Kind;
|
||||
AmountMode = settings.AmountMode;
|
||||
AmountValue = Math.Max(1, settings.AmountValue);
|
||||
IsRequired = settings.IsRequired;
|
||||
ChoiceKey = string.IsNullOrWhiteSpace(settings.ChoiceKey)
|
||||
? null
|
||||
: settings.ChoiceKey.Trim();
|
||||
ChoiceWeight = Math.Max(0, settings.ChoiceWeight);
|
||||
ConditionsJson = string.IsNullOrWhiteSpace(settings.ConditionsJson)
|
||||
? null
|
||||
: settings.ConditionsJson;
|
||||
|
||||
// Источник зависит от типа врезки: у заставки нет группы, у остальных нет блока заставки.
|
||||
// Оставленная от прежнего типа ссылка потом читалась бы генератором как настройка.
|
||||
GroupId = kind == JunctionElementKind.Bumper ? null : groupId;
|
||||
BumperTemplateId = kind == JunctionElementKind.Bumper ? bumperTemplateId : null;
|
||||
var isBumper = settings.Kind == JunctionElementKind.Bumper;
|
||||
GroupId = isBumper ? null : settings.GroupId;
|
||||
BumperTemplateId = isBumper ? settings.BumperTemplateId : null;
|
||||
BumperVariantId = isBumper ? settings.BumperVariantId : null;
|
||||
}
|
||||
|
||||
/// <summary>Перевесить врезку в другую развилку (или вынести из неё) — это делает перетаскивание.</summary>
|
||||
public void SetChoice(string? choiceKey, int weight)
|
||||
{
|
||||
ChoiceKey = string.IsNullOrWhiteSpace(choiceKey) ? null : choiceKey.Trim();
|
||||
ChoiceWeight = Math.Max(0, weight);
|
||||
}
|
||||
|
||||
internal void SetPosition(int position) => Position = position;
|
||||
|
||||
@@ -4,14 +4,21 @@ namespace TeleWave.Domain.Programming;
|
||||
/// Шаблон стыка: что играет между программами. Реклама и заставки перестают быть свойствами канала
|
||||
/// и становятся врезками стыка — за счёт этого в прайм можно поставить три ролика и заставку,
|
||||
/// а ночью один длинный ролик, чего одной настройкой на канал не сделать.
|
||||
///
|
||||
/// Стык общий для всех каналов, как группа: «рекламный блок на две минуты с заставкой в конце» —
|
||||
/// такой же переиспользуемый ресурс, как «Боевики 90-х». Канальным остаётся только выбор, какой
|
||||
/// стык поставить в слот.
|
||||
/// </summary>
|
||||
public class JunctionTemplate
|
||||
{
|
||||
private readonly List<JunctionElement> _elements = [];
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
public Guid ChannelId { get; private set; }
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Потолок длины стыка целиком в секундах; null — ограничен только якорем.</summary>
|
||||
public int? MaxTotalSeconds { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
/// <summary>Врезки в порядке показа (backing-field для EF).</summary>
|
||||
@@ -19,16 +26,19 @@ public class JunctionTemplate
|
||||
|
||||
private JunctionTemplate() { }
|
||||
|
||||
public static JunctionTemplate Create(Guid channelId, string name) =>
|
||||
public static JunctionTemplate Create(string name) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChannelId = channelId,
|
||||
Name = name.Trim(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
public void Rename(string name) => Name = name.Trim();
|
||||
public void Update(string name, int? maxTotalSeconds)
|
||||
{
|
||||
Name = name.Trim();
|
||||
MaxTotalSeconds = maxTotalSeconds is > 0 ? maxTotalSeconds : null;
|
||||
}
|
||||
|
||||
public JunctionElement AddElement(JunctionElementKind kind)
|
||||
{
|
||||
@@ -47,6 +57,7 @@ public class JunctionTemplate
|
||||
if (element is null)
|
||||
return false;
|
||||
_elements.Remove(element);
|
||||
Normalize(_elements.OrderBy(e => e.Position).Select(e => e.Id));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -63,8 +74,38 @@ public class JunctionTemplate
|
||||
.OrderBy(e => e.Position)
|
||||
.Select(e => e.Id);
|
||||
|
||||
Normalize(requested.Concat(rest));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Расставляет позиции, стягивая врезки одной развилки в непрерывный отрезок: развилка — это одно
|
||||
/// место в цепочке, и её участники, разбросанные по стыку, не имели бы смысла. Каждая приезжает
|
||||
/// к первому упоминанию своей метки, поэтому перетащить развилку целиком можно за любого участника.
|
||||
/// </summary>
|
||||
private void Normalize(IEnumerable<Guid> order)
|
||||
{
|
||||
var byId = _elements.ToDictionary(e => e.Id);
|
||||
var ordered = order.Select(id => byId[id]).ToList();
|
||||
|
||||
var clustered = new List<JunctionElement>();
|
||||
var placedChoices = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var element in ordered)
|
||||
{
|
||||
if (element.ChoiceKey is not { } key)
|
||||
{
|
||||
clustered.Add(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!placedChoices.Add(key))
|
||||
continue;
|
||||
|
||||
clustered.AddRange(ordered.Where(e => e.ChoiceKey == key));
|
||||
}
|
||||
|
||||
var position = 0;
|
||||
foreach (var id in requested.Concat(rest))
|
||||
_elements.First(e => e.Id == id).SetPosition(position++);
|
||||
foreach (var element in clustered)
|
||||
element.SetPosition(position++);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,76 @@
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
|
||||
namespace TeleWave.Domain.Programming.Planning;
|
||||
|
||||
/// <summary>Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз.</summary>
|
||||
/// <summary>
|
||||
/// Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз и на какой единице
|
||||
/// группы она остановилась.
|
||||
///
|
||||
/// Ключ — конкретная врезка, а не её вид: две рекламные врезки в разных стыках это разные
|
||||
/// ограничения, общий счётчик на вид склеил бы их в одно.
|
||||
/// </summary>
|
||||
public sealed class JunctionHistory
|
||||
{
|
||||
private readonly Dictionary<JunctionElementKind, DateTimeOffset> _lastPlaced = [];
|
||||
private readonly Dictionary<Guid, DateTimeOffset> _lastPlaced = [];
|
||||
private readonly Dictionary<Guid, int> _nextUnit = [];
|
||||
|
||||
public bool Allows(PlanningJunctionElement element, DateTimeOffset moment)
|
||||
{
|
||||
if (element.MinMinutesBetween <= 0)
|
||||
return true;
|
||||
if (!_lastPlaced.TryGetValue(element.Kind, out var last))
|
||||
if (!_lastPlaced.TryGetValue(element.ElementId, out var last))
|
||||
return true;
|
||||
return moment - last >= TimeSpan.FromMinutes(element.MinMinutesBetween);
|
||||
}
|
||||
|
||||
public void Record(PlanningJunctionElement element, DateTimeOffset moment) =>
|
||||
_lastPlaced[element.Kind] = moment;
|
||||
_lastPlaced[element.ElementId] = moment;
|
||||
|
||||
/// <summary>
|
||||
/// С какой единицы группы врезка продолжает набор. Без этого каждый рекламный блок начинался бы
|
||||
/// с одного и того же ролика — в эфире это слышно с первой же секунды.
|
||||
/// </summary>
|
||||
public int NextUnit(PlanningJunctionElement element) =>
|
||||
_nextUnit.TryGetValue(element.ElementId, out var index) ? index : 0;
|
||||
|
||||
public void AdvanceUnits(PlanningJunctionElement element, int consumed)
|
||||
{
|
||||
if (element.Units.Count == 0 || consumed <= 0)
|
||||
return;
|
||||
_nextUnit[element.ElementId] = (NextUnit(element) + consumed) % element.Units.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти четыре значения ехали
|
||||
/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти значения ехали
|
||||
/// сквозь всю раскладку и вместе с курсором, пределом и накопителями раздували сигнатуры.
|
||||
/// </summary>
|
||||
/// <param name="FromShowId">Шоу перед стыком, <paramref name="ToShowId"/> — после; заставке нужна
|
||||
/// именно пара соседей, её ассет рендерится под неё после сборки ленты.</param>
|
||||
/// <param name="ChannelOffset">Смещение времени канала от UTC — по нему считается окно суток врезки.</param>
|
||||
public sealed record JunctionPlacement(
|
||||
Guid SlotId,
|
||||
Guid? FromShowId,
|
||||
Guid? ToShowId,
|
||||
bool ElementChanged
|
||||
bool ElementChanged,
|
||||
TimeSpan ChannelOffset = default
|
||||
);
|
||||
|
||||
/// <summary>Готовая к постановке врезка: что именно играет и сколько это займёт.</summary>
|
||||
public sealed record JunctionInsert(
|
||||
PlanningJunctionElement Element,
|
||||
IReadOnlyList<PlanningUnit> Units,
|
||||
TimeSpan Duration
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. Ставит только то, что влезает
|
||||
/// целиком до предела (якорь или горизонт) — обрезать врезку нельзя, а перехлёст сдвинул бы якорь.
|
||||
/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель.
|
||||
///
|
||||
/// Обязательные врезки (<see cref="PlanningJunctionElement.IsRequired"/>) идут первыми: при нехватке
|
||||
/// времени выбрасываются необязательные, а не то, ради чего стык и заведён.
|
||||
/// Порядок показа — это <see cref="JunctionElement.Position"/>, и только он. Обязательность
|
||||
/// (<see cref="PlanningJunctionElement.IsRequired"/>) участвует единственным способом: когда до
|
||||
/// предела (якорь или горизонт) влезает не всё, с конца отбрасываются необязательные врезки. Иначе
|
||||
/// галочка «обязательно» молча поднимала бы рекламу перед заставкой, и цепочка в редакторе
|
||||
/// перестала бы соответствовать эфиру.
|
||||
/// </summary>
|
||||
public static class JunctionFiller
|
||||
{
|
||||
@@ -47,6 +81,7 @@ public static class JunctionFiller
|
||||
DateTimeOffset limit,
|
||||
JunctionPlacement placement,
|
||||
JunctionHistory history,
|
||||
IRandomSource random,
|
||||
List<PlannedItem> items,
|
||||
PlanTrace? trace
|
||||
)
|
||||
@@ -54,37 +89,191 @@ public static class JunctionFiller
|
||||
if (junction is null || junction.Elements.Count == 0)
|
||||
return cursor;
|
||||
|
||||
var ordered = junction
|
||||
.Elements.OrderByDescending(e => e.IsRequired)
|
||||
.ThenBy(e => junction.Elements.ToList().IndexOf(e))
|
||||
var eligible = junction
|
||||
.Elements.Where(e => Passes(e, cursor, placement, history, random))
|
||||
.ToList();
|
||||
var chosen = ResolveChoices(eligible, random);
|
||||
|
||||
foreach (var element in ordered)
|
||||
var available = limit - cursor;
|
||||
if (junction.MaxTotal is { } cap && cap < available)
|
||||
available = cap;
|
||||
if (available <= TimeSpan.Zero)
|
||||
return cursor;
|
||||
|
||||
var inserts = Trim(chosen.Select(e => Build(e, history)).ToList(), available);
|
||||
|
||||
foreach (var insert in inserts)
|
||||
{
|
||||
if (element.OnlyOnElementChange && !placement.ElementChanged)
|
||||
continue;
|
||||
if (!history.Allows(element, cursor))
|
||||
continue;
|
||||
|
||||
var placedAt = cursor;
|
||||
var before = items.Count;
|
||||
cursor =
|
||||
element.Kind == JunctionElementKind.Bumper
|
||||
? PlaceBumper(element, cursor, limit, placement, items, trace)
|
||||
: PlaceUnits(element, cursor, limit, placement.SlotId, items, trace);
|
||||
insert.Element.Kind == JunctionElementKind.Bumper
|
||||
? PlaceBumper(insert, cursor, limit, placement, items, trace)
|
||||
: PlaceUnits(insert, cursor, limit, placement.SlotId, items, trace);
|
||||
|
||||
if (cursor > placedAt)
|
||||
history.Record(element, placedAt);
|
||||
if (cursor <= placedAt)
|
||||
continue;
|
||||
|
||||
history.Record(insert.Element, placedAt);
|
||||
// Двигаем ротацию ровно на поставленное: часть единиц могла не влезть до предела.
|
||||
history.AdvanceUnits(insert.Element, items.Count - before);
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/// <summary>Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, жребий.</summary>
|
||||
private static bool Passes(
|
||||
PlanningJunctionElement element,
|
||||
DateTimeOffset cursor,
|
||||
JunctionPlacement placement,
|
||||
JunctionHistory history,
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
if (element.OnlyOnElementChange && !placement.ElementChanged)
|
||||
return false;
|
||||
if (!history.Allows(element, cursor))
|
||||
return false;
|
||||
|
||||
if (element.TimeWindow is { } window)
|
||||
{
|
||||
var local = TimeOnly.FromTimeSpan(cursor.ToOffset(placement.ChannelOffset).TimeOfDay);
|
||||
if (!window.Contains(local))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации,
|
||||
// поэтому пересборка хвоста не перетасовывает врезки на каждое применение.
|
||||
return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Схлопывает развилки: из врезок с одной меткой остаётся одна, выбранная по весам. Позиция
|
||||
/// развилки в цепочке — позиция её первого участника.
|
||||
/// </summary>
|
||||
private static List<PlanningJunctionElement> ResolveChoices(
|
||||
IReadOnlyList<PlanningJunctionElement> elements,
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
var result = new List<PlanningJunctionElement>();
|
||||
var resolved = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (element.ChoiceKey is not { } key)
|
||||
{
|
||||
result.Add(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!resolved.Add(key))
|
||||
continue;
|
||||
|
||||
var candidates = elements.Where(e => e.ChoiceKey == key).ToList();
|
||||
result.Add(WeightedPick(candidates, random));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PlanningJunctionElement WeightedPick(
|
||||
IReadOnlyList<PlanningJunctionElement> candidates,
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
var total = candidates.Sum(c => Math.Max(0, c.ChoiceWeight));
|
||||
if (total <= 0)
|
||||
return candidates[random.Next(candidates.Count)];
|
||||
|
||||
var roll = random.Next(total);
|
||||
var accumulated = 0;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
accumulated += Math.Max(0, candidate.ChoiceWeight);
|
||||
if (roll < accumulated)
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return candidates[^1];
|
||||
}
|
||||
|
||||
/// <summary>Подбирает единицы врезки по её бюджету, не двигая состояние: ставить или нет — решит <see cref="Trim"/>.</summary>
|
||||
private static JunctionInsert Build(PlanningJunctionElement element, JunctionHistory history)
|
||||
{
|
||||
if (element.Kind == JunctionElementKind.Bumper)
|
||||
return new JunctionInsert(element, [], element.BumperDuration);
|
||||
|
||||
var units = new List<PlanningUnit>();
|
||||
var accumulated = TimeSpan.Zero;
|
||||
var start = history.NextUnit(element);
|
||||
|
||||
for (var taken = 0; taken < element.Units.Count; taken++)
|
||||
{
|
||||
var enough =
|
||||
element.AmountMode == JunctionAmountMode.Count
|
||||
? units.Count >= Math.Max(1, element.AmountValue)
|
||||
// Последняя единица входит целиком: рекламный блок не разрезается.
|
||||
: accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue));
|
||||
if (enough)
|
||||
break;
|
||||
|
||||
var unit = element.Units[(start + taken) % element.Units.Count];
|
||||
if (unit.Duration <= TimeSpan.Zero)
|
||||
continue;
|
||||
|
||||
units.Add(unit);
|
||||
accumulated += unit.Duration;
|
||||
}
|
||||
|
||||
return new JunctionInsert(element, units, accumulated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ужимает стык под <paramref name="available"/>: с конца отбрасываются сначала лишние единицы
|
||||
/// необязательных врезок, потом они сами целиком, и только если этого не хватило — обязательные.
|
||||
///
|
||||
/// Ужимаем поштучно, а не целыми врезками: рекламный блок из четырёх роликов, для которого до
|
||||
/// якоря осталось место под один, должен поставить один, а не пропасть целиком.
|
||||
/// </summary>
|
||||
private static List<JunctionInsert> Trim(List<JunctionInsert> inserts, TimeSpan available)
|
||||
{
|
||||
var kept = inserts.Where(i => i.Duration > TimeSpan.Zero).ToList();
|
||||
var total = kept.Aggregate(TimeSpan.Zero, (sum, i) => sum + i.Duration);
|
||||
|
||||
foreach (var required in new[] { false, true })
|
||||
{
|
||||
for (var i = kept.Count - 1; i >= 0 && total > available; i--)
|
||||
{
|
||||
if (kept[i].Element.IsRequired != required)
|
||||
continue;
|
||||
|
||||
while (total > available && kept[i].Units.Count > 1)
|
||||
{
|
||||
var units = kept[i].Units.Take(kept[i].Units.Count - 1).ToList();
|
||||
var duration = units.Aggregate(TimeSpan.Zero, (sum, u) => sum + u.Duration);
|
||||
total -= kept[i].Duration - duration;
|
||||
kept[i] = kept[i] with { Units = units, Duration = duration };
|
||||
}
|
||||
|
||||
if (total <= available)
|
||||
break;
|
||||
|
||||
total -= kept[i].Duration;
|
||||
kept.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Резервирует место под заставку. Ассет пуст: он рендерится под конкретную пару «из/в» уже после
|
||||
/// того, как лента собрана, — до наполнения слотов пара попросту неизвестна.
|
||||
/// </summary>
|
||||
private static DateTimeOffset PlaceBumper(
|
||||
PlanningJunctionElement element,
|
||||
JunctionInsert insert,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset limit,
|
||||
JunctionPlacement placement,
|
||||
@@ -92,10 +281,10 @@ public static class JunctionFiller
|
||||
PlanTrace? trace
|
||||
)
|
||||
{
|
||||
if (element.BumperDuration <= TimeSpan.Zero || cursor + element.BumperDuration > limit)
|
||||
if (insert.Duration <= TimeSpan.Zero || cursor + insert.Duration > limit)
|
||||
return cursor;
|
||||
|
||||
var end = cursor + element.BumperDuration;
|
||||
var end = cursor + insert.Duration;
|
||||
items.Add(
|
||||
new PlannedItem(
|
||||
Guid.Empty,
|
||||
@@ -106,17 +295,18 @@ public static class JunctionFiller
|
||||
placement.SlotId,
|
||||
PlannedItemKind.Bumper,
|
||||
trace,
|
||||
element.BumperTemplateId,
|
||||
insert.Element.BumperTemplateId,
|
||||
placement.FromShowId,
|
||||
placement.ToShowId
|
||||
placement.ToShowId,
|
||||
insert.Element.BumperVariantId
|
||||
)
|
||||
);
|
||||
return end;
|
||||
}
|
||||
|
||||
/// <summary>Ставит единицы врезки по её бюджету: N штук либо пока не наберётся M минут.</summary>
|
||||
/// <summary>Ставит подобранные единицы врезки; те, что не влезают до предела, отбрасываются.</summary>
|
||||
private static DateTimeOffset PlaceUnits(
|
||||
PlanningJunctionElement element,
|
||||
JunctionInsert insert,
|
||||
DateTimeOffset cursor,
|
||||
DateTimeOffset limit,
|
||||
Guid slotId,
|
||||
@@ -124,34 +314,16 @@ public static class JunctionFiller
|
||||
PlanTrace? trace
|
||||
)
|
||||
{
|
||||
if (element.Units.Count == 0)
|
||||
return cursor;
|
||||
|
||||
var kind = element.Kind switch
|
||||
var kind = insert.Element.Kind switch
|
||||
{
|
||||
JunctionElementKind.Ad => PlannedItemKind.Ad,
|
||||
JunctionElementKind.Promo => PlannedItemKind.Promo,
|
||||
_ => PlannedItemKind.Fallback,
|
||||
};
|
||||
|
||||
var placed = 0;
|
||||
var accumulated = TimeSpan.Zero;
|
||||
var index = 0;
|
||||
|
||||
while (index < element.Units.Count)
|
||||
foreach (var unit in insert.Units)
|
||||
{
|
||||
var unit = element.Units[index];
|
||||
index++;
|
||||
|
||||
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > limit)
|
||||
break;
|
||||
|
||||
var enough =
|
||||
element.AmountMode == JunctionAmountMode.Count
|
||||
? placed >= Math.Max(1, element.AmountValue)
|
||||
// Последняя единица входит целиком: рекламный блок не разрезается.
|
||||
: accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue));
|
||||
if (enough)
|
||||
if (cursor + unit.Duration > limit)
|
||||
break;
|
||||
|
||||
items.Add(
|
||||
@@ -167,8 +339,6 @@ public static class JunctionFiller
|
||||
)
|
||||
);
|
||||
cursor += unit.Duration;
|
||||
accumulated += unit.Duration;
|
||||
placed++;
|
||||
}
|
||||
|
||||
return cursor;
|
||||
|
||||
@@ -85,11 +85,19 @@ public sealed record PlanningSlot(
|
||||
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
|
||||
}
|
||||
|
||||
/// <summary>Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00).</summary>
|
||||
public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To)
|
||||
{
|
||||
public bool Contains(TimeOnly moment) =>
|
||||
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
|
||||
/// решить, сколько их поставить и влезают ли они.
|
||||
/// </summary>
|
||||
public sealed record PlanningJunctionElement(
|
||||
Guid ElementId,
|
||||
JunctionElementKind Kind,
|
||||
IReadOnlyList<PlanningUnit> Units,
|
||||
JunctionAmountMode AmountMode,
|
||||
@@ -99,8 +107,17 @@ public sealed record PlanningJunctionElement(
|
||||
bool OnlyOnElementChange = false,
|
||||
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
||||
int MinMinutesBetween = 0,
|
||||
/// <summary>Вероятность показа в процентах (100 — всегда).</summary>
|
||||
int Chance = 100,
|
||||
/// <summary>Окно времени суток, вне которого врезка не ставится (null — всегда).</summary>
|
||||
PlanningTimeWindow? TimeWindow = null,
|
||||
/// <summary>Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам.</summary>
|
||||
string? ChoiceKey = null,
|
||||
int ChoiceWeight = 1,
|
||||
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
|
||||
Guid? BumperTemplateId = null,
|
||||
/// <summary>Конкретный подблок заставки; null — выберет резолвер по триггеру и весам.</summary>
|
||||
Guid? BumperVariantId = null,
|
||||
/// <summary>Длительность резерва под заставку.</summary>
|
||||
TimeSpan BumperDuration = default
|
||||
);
|
||||
@@ -108,7 +125,9 @@ public sealed record PlanningJunctionElement(
|
||||
/// <summary>Стык: последовательность врезок между программами.</summary>
|
||||
public sealed record PlanningJunction(
|
||||
Guid JunctionId,
|
||||
IReadOnlyList<PlanningJunctionElement> Elements
|
||||
IReadOnlyList<PlanningJunctionElement> Elements,
|
||||
/// <summary>Потолок длины стыка целиком (null — ограничен только якорем).</summary>
|
||||
TimeSpan? MaxTotal = null
|
||||
);
|
||||
|
||||
/// <summary>Полный вход одного прогона генератора.</summary>
|
||||
@@ -119,7 +138,9 @@ public sealed record PlanningInput(
|
||||
IReadOnlyList<PlanningSlot> Slots,
|
||||
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
|
||||
IReadOnlyList<PlanningUnit> FallbackUnits,
|
||||
int SegmentSeconds
|
||||
int SegmentSeconds,
|
||||
/// <summary>Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка.</summary>
|
||||
int UtcOffsetMinutes = 0
|
||||
);
|
||||
|
||||
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
|
||||
@@ -136,6 +157,8 @@ public sealed record PlannedItem(
|
||||
Guid? BumperTemplateId = null,
|
||||
Guid? FromShowId = null,
|
||||
Guid? ToShowId = null,
|
||||
/// <summary>Подблок заставки, если врезка задала его жёстко; null — выберет резолвер.</summary>
|
||||
Guid? BumperVariantId = null,
|
||||
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
|
||||
Guid? CollectionId = null
|
||||
);
|
||||
|
||||
@@ -31,6 +31,9 @@ public static class SchedulePlanner
|
||||
public List<PlanningWarning> Warnings { get; } = [];
|
||||
public JunctionHistory Junctions { get; } = new();
|
||||
|
||||
/// <summary>Смещение времени канала — врезки со своим окном суток считают его по нему.</summary>
|
||||
public TimeSpan ChannelOffset { get; } = TimeSpan.FromMinutes(input.UtcOffsetMinutes);
|
||||
|
||||
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
|
||||
public Guid? PreviousShowId { get; set; }
|
||||
}
|
||||
@@ -254,9 +257,11 @@ public static class SchedulePlanner
|
||||
slot.SlotId,
|
||||
run.PreviousShowId,
|
||||
unit.ShowId,
|
||||
ElementChanged: run.PreviousShowId != unit.ShowId
|
||||
ElementChanged: run.PreviousShowId != unit.ShowId,
|
||||
run.ChannelOffset
|
||||
),
|
||||
run.Junctions,
|
||||
run.Random,
|
||||
run.Items,
|
||||
slotTrace
|
||||
);
|
||||
@@ -282,8 +287,15 @@ public static class SchedulePlanner
|
||||
slot.JunctionAfter,
|
||||
cursor,
|
||||
limit,
|
||||
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
|
||||
new JunctionPlacement(
|
||||
slot.SlotId,
|
||||
run.PreviousShowId,
|
||||
null,
|
||||
ElementChanged: true,
|
||||
run.ChannelOffset
|
||||
),
|
||||
run.Junctions,
|
||||
run.Random,
|
||||
run.Items,
|
||||
slotTrace
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user