Implement BumperEndpoints and remove deprecated bumper-related functionality
ci / build-backend (push) Successful in 1m39s
ci / build-frontend (push) Failing after 26s
ci / tests (push) Skipped
ci / sonar (push) Skipped

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:
Leonid Pershin
2026-07-27 22:01:56 +03:00
parent ee0b4d2d01
commit ba3721eb92
140 changed files with 7326 additions and 3609 deletions
@@ -57,14 +57,10 @@ public sealed class GridScheduleGenerator(
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
// Вложенные коллекции тянем отдельными запросами: иначе колонки родителя (у шаблона —
// jsonb с правилами, у слоя — jsonb применимости) приезжают по копии на каждую строку
// листа. Тик планировщика повторяет эти два запроса на каждый канал.
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == channelId,
cancellationToken
);
if (channel is null || !channel.IsEnabled || channel.TemplateId is null)
return new GenerationReport(0, [], ChannelSkipped: true);
@@ -129,12 +125,13 @@ public sealed class GridScheduleGenerator(
);
var added = 0;
foreach (var item in result.Items)
for (var index = 0; index < result.Items.Count; index++)
{
var item = result.Items[index];
var assetId = item.MediaAssetId;
if (
item.Kind == PlannedItemKind.Bumper
&& !TryResolveBumper(item, bumperAssets, out assetId)
&& !bumperAssets.TryGetValue(index, out assetId)
)
continue; // Без ассета запись стала бы дырой в ленте.
@@ -184,9 +181,6 @@ public sealed class GridScheduleGenerator(
{
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
if (channel is null || channel.TemplateId is null)
return null;
@@ -280,13 +274,17 @@ public sealed class GridScheduleGenerator(
);
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
// что и группы контента.
// что и группы контента. Стыки общие, поэтому фильтра по каналу нет.
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == channel.Id)
.ToDictionaryAsync(j => j.Id, cancellationToken);
// Блоки заставок нужны только длительностью — текст подставит резолвер после сборки ленты.
var bumperTemplates = await dbContext
.BumperTemplates.AsNoTracking()
.ToDictionaryAsync(t => t.Id, cancellationToken);
var groupIds = scheduled
.Select(s => s.Slot.GroupId)
.Where(id => id is not null)
@@ -361,12 +359,19 @@ public sealed class GridScheduleGenerator(
elements,
cursor,
repeatUnits,
BuildJunction(slot.JunctionBetweenId, junctions, elementsByGroup, channel),
BuildJunction(
slot.JunctionBetweenId,
junctions,
elementsByGroup,
bumperTemplates,
slot.Daypart
),
BuildJunction(
slot.JunctionAfterId ?? template.DefaultJunctionId,
junctions,
elementsByGroup,
channel
bumperTemplates,
slot.Daypart
),
rules?.AudienceAt(
TimeOnly.FromDateTime(
@@ -388,44 +393,18 @@ public sealed class GridScheduleGenerator(
horizonEnd,
slots,
fallback,
_segmentSeconds
_segmentSeconds,
channel.UtcOffsetMinutes
);
}
/// <summary>Ассет заставки по зарезервированной записи; false — подобрать не удалось.</summary>
private static bool TryResolveBumper(
PlannedItem item,
IReadOnlyDictionary<BumperKey, Guid> bumperAssets,
out Guid assetId
)
{
assetId = Guid.Empty;
if (item.BumperTemplateId is not { } templateId)
return false;
// Подблок выбирает резолвер, поэтому ищем по блоку и паре шоу.
foreach (var pair in bumperAssets)
{
if (
pair.Key.TemplateId == templateId
&& pair.Key.FromShowId == (item.FromShowId ?? Guid.Empty)
&& pair.Key.ToShowId == (item.ToShowId ?? Guid.Empty)
)
{
assetId = pair.Value;
return true;
}
}
return false;
}
/// <summary>Разворачивает шаблон стыка для планировщика, включая резерв под заставки.</summary>
private PlanningJunction? BuildJunction(
Guid? junctionId,
IReadOnlyDictionary<Guid, JunctionTemplate> junctions,
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
Channel channel
IReadOnlyDictionary<Guid, BumperTemplate> bumperTemplates,
Daypart daypart
)
{
if (junctionId is not { } id || !junctions.TryGetValue(id, out var template))
@@ -437,57 +416,83 @@ public sealed class GridScheduleGenerator(
var conditions =
JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions();
if (element.Kind == JunctionElementKind.Bumper)
{
// Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
// резервирует именно её, ассет подставит резолвер после сборки ленты.
if (
element.BumperTemplateId is not { } bumperTemplateId
|| channel.FindBumperTemplate(bumperTemplateId) is not { } bumperTemplate
)
continue;
var seconds = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(bumperTemplate),
_segmentSeconds
);
elements.Add(
new PlanningJunctionElement(
element.Kind,
[],
element.AmountMode,
element.AmountValue,
element.IsRequired,
conditions.OnlyOnElementChange,
conditions.MinMinutesBetween,
bumperTemplateId,
TimeSpan.FromSeconds(seconds)
)
);
// Дейпарт — свойство слота, а не момента: отсекаем здесь, чтобы домен не знал про сетку.
if (!conditions.AllowsDaypart(daypart))
continue;
}
if (
element.GroupId is not { } groupId
|| !elementsByGroup.TryGetValue(groupId, out var groupElements)
)
var units = ResolveUnits(element, elementsByGroup, bumperTemplates, out var bumper);
if (units is null)
continue;
elements.Add(
new PlanningJunctionElement(
element.Id,
element.Kind,
groupElements.SelectMany(e => e.Units).ToList(),
units,
element.AmountMode,
element.AmountValue,
element.IsRequired,
conditions.OnlyOnElementChange,
conditions.MinMinutesBetween
conditions.MinMinutesBetween,
conditions.Chance,
conditions.TimeWindow is { } window
? new PlanningTimeWindow(window.From, window.To)
: null,
element.ChoiceKey,
element.ChoiceWeight,
element.BumperTemplateId,
element.BumperVariantId,
bumper
)
);
}
return elements.Count == 0 ? null : new PlanningJunction(id, elements);
return elements.Count == 0
? null
: new PlanningJunction(
id,
elements,
template.MaxTotalSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : null
);
}
/// <summary>
/// Что играет во врезке: единицы группы либо резерв под заставку. null — врезка настроена
/// не до конца (нет группы или блока), и в эфир ей идти нечем.
/// </summary>
private IReadOnlyList<PlanningUnit>? ResolveUnits(
JunctionElement element,
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
IReadOnlyDictionary<Guid, BumperTemplate> bumperTemplates,
out TimeSpan bumperDuration
)
{
bumperDuration = TimeSpan.Zero;
if (element.Kind == JunctionElementKind.Bumper)
{
// Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
// резервирует именно её, ассет подставит резолвер после сборки ленты.
if (
element.BumperTemplateId is not { } templateId
|| !bumperTemplates.TryGetValue(templateId, out var bumperTemplate)
)
return null;
bumperDuration = TimeSpan.FromSeconds(
BumperDuration.Aligned(
BumperDuration.TemplateSeconds(bumperTemplate),
_segmentSeconds
)
);
return [];
}
return
element.GroupId is { } groupId
&& elementsByGroup.TryGetValue(groupId, out var groupElements)
? groupElements.SelectMany(e => e.Units).ToList()
: null;
}
/// <summary>