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
@@ -0,0 +1,223 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Данные, которыми подставляются плейсхолдеры заставок одного прогона: названия шоу, годы, жанры,
/// подписи серий, названия слотов.
///
/// Грузится только запрошенное: если ни в одной строке нет <c>{next.genre}</c>, жанры не читаются
/// вовсе. Иначе каждая генерация тянула бы весь справочник ради текста, который никто не написал.
/// </summary>
internal sealed class BumperFacts
{
private readonly IReadOnlyList<PlannedItem> _items;
private readonly TimeSpan _offset;
private readonly Channel _channel;
private readonly IReadOnlyDictionary<Guid, ShowFact> _shows;
private readonly IReadOnlyDictionary<Guid, string> _slotTitles;
private BumperFacts(
IReadOnlyList<PlannedItem> items,
Channel channel,
IReadOnlyDictionary<Guid, ShowFact> shows,
IReadOnlyDictionary<Guid, string> slotTitles
)
{
_items = items;
_channel = channel;
_offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
_shows = shows;
_slotTitles = slotTitles;
}
public static async Task<BumperFacts> LoadAsync(
IAppDbContext dbContext,
Channel channel,
IReadOnlyList<PlannedItem> items,
IReadOnlySet<string> tokens,
CancellationToken cancellationToken
)
{
var showIds = items
.Where(i => i.Kind == PlannedItemKind.Bumper)
.SelectMany(i => new[] { i.FromShowId, i.ToShowId })
.Where(id => id is not null && id != Guid.Empty)
.Select(id => id!.Value)
.Distinct()
.ToList();
var shows =
showIds.Count == 0
? []
: await LoadShowsAsync(dbContext, showIds, tokens, cancellationToken);
var slotTitles = tokens.Contains("slot")
? await LoadSlotTitlesAsync(dbContext, items, cancellationToken)
: new Dictionary<Guid, string>();
return new BumperFacts(items, channel, shows, slotTitles);
}
/// <summary>Контекст одной заставки: соседи по ленте, время показа и данные канала.</summary>
public BumperContext Context(PlannedItem item, int index)
{
var from = Show(item.FromShowId);
var to = Show(item.ToShowId);
var nextProgram = FindProgram(index, forward: true);
var previousProgram = FindProgram(index, forward: false);
return new BumperContext(
_channel.Name,
_channel.Number,
item.StartsAtUtc.ToOffset(_offset),
from?.Name,
to?.Name,
EpisodeOf(from, previousProgram, item.FromShowId),
EpisodeOf(to, nextProgram, item.ToShowId),
to?.Year,
to?.Genre,
nextProgram is { } next
? TimeOnly.FromDateTime(next.StartsAtUtc.ToOffset(_offset).DateTime)
: null,
item.SlotId is { } slotId && _slotTitles.TryGetValue(slotId, out var title)
? title
: null
);
}
/// <summary>Подпись серии соседней программы — только если это та же самая программа.</summary>
private static string? EpisodeOf(ShowFact? show, PlannedItem? neighbour, Guid? showId)
{
if (show is null || neighbour is null || neighbour.ShowId != showId)
return null;
return neighbour.UnitIndex is { } index && index >= 0 && index < show.Episodes.Count
? show.Episodes[index]
: null;
}
private ShowFact? Show(Guid? showId) =>
showId is { } id && _shows.TryGetValue(id, out var fact) ? fact : null;
/// <summary>Ближайшая программа по ленте в заданную сторону — стык может быть длиннее одной врезки.</summary>
private PlannedItem? FindProgram(int index, bool forward)
{
var step = forward ? 1 : -1;
for (var i = index + step; i >= 0 && i < _items.Count; i += step)
if (_items[i].Kind == PlannedItemKind.Program)
return _items[i];
return null;
}
private static async Task<Dictionary<Guid, ShowFact>> LoadShowsAsync(
IAppDbContext dbContext,
IReadOnlyList<Guid> showIds,
IReadOnlySet<string> tokens,
CancellationToken cancellationToken
)
{
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.Year,
})
.ToListAsync(cancellationToken);
var genres = tokens.Contains("next.genre")
? await (
from link in dbContext.ShowGenres.AsNoTracking()
join genre in dbContext.Genres.AsNoTracking() on link.GenreId equals genre.Id
where showIds.Contains(link.ShowId) && link.IsPrimary
select new { link.ShowId, genre.Name }
).ToDictionaryAsync(g => g.ShowId, g => g.Name, cancellationToken)
: [];
var episodes =
tokens.Contains("next.episode") || tokens.Contains("now.episode")
? await LoadEpisodesAsync(dbContext, showIds, cancellationToken)
: [];
return shows.ToDictionary(
s => s.Id,
s => new ShowFact(
s.Name,
s.Year,
genres.GetValueOrDefault(s.Id),
episodes.GetValueOrDefault(s.Id) ?? []
)
);
}
/// <summary>
/// Подписи серий в том же порядке, в каком их разворачивает планировщик: только серии с готовым
/// ассетом, по позиции. Иначе номер в заставке разошёлся бы с тем, что реально играет.
/// </summary>
private static async Task<Dictionary<Guid, List<string?>>> LoadEpisodesAsync(
IAppDbContext dbContext,
IReadOnlyList<Guid> showIds,
CancellationToken cancellationToken
)
{
var rows = await (
from show in dbContext.Shows.AsNoTracking()
from episode in show.Episodes
join asset in dbContext.MediaAssets.AsNoTracking()
on episode.MediaAssetId equals asset.Id
where
showIds.Contains(show.Id)
&& asset.Status == MediaAssetStatus.Ready
&& asset.Duration != null
orderby episode.Position
select new
{
show.Id,
episode.Season,
episode.Episode,
episode.Title,
}
).ToListAsync(cancellationToken);
return rows.GroupBy(r => r.Id)
.ToDictionary(
g => g.Key,
g =>
g.Select(r => BumperPlaceholders.Episode(r.Season, r.Episode) ?? r.Title)
.ToList<string?>()
);
}
private static async Task<Dictionary<Guid, string>> LoadSlotTitlesAsync(
IAppDbContext dbContext,
IReadOnlyList<PlannedItem> items,
CancellationToken cancellationToken
)
{
var slotIds = items
.Select(i => i.SlotId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
return await dbContext
.Slots.AsNoTracking()
.Where(s => slotIds.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken);
}
private sealed record ShowFact(
string Name,
int? Year,
string? Genre,
IReadOnlyList<string?> Episodes
);
}
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
@@ -10,21 +11,14 @@ using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>Ключ отрендеренной заставки: блок, подблок и пара шоу, между которыми она стоит.</summary>
public readonly record struct BumperKey(
Guid TemplateId,
Guid VariantId,
Guid FromShowId,
Guid ToShowId
);
/// <summary>
/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены
/// намеренно: ассет зависит от пары соседей, а пара известна только после того, как слоты наполнены.
/// намеренно: текст заставки зависит от пары соседей и времени показа, а они известны только после
/// того, как слоты наполнены.
///
/// Готовый ассет переиспользуется по сигнатуре (пара названий + версия блока), недостающий
/// регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру. Запись при
/// этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
/// Готовый ассет переиспользуется по сигнатуре содержимого (оформление блока + подставленный текст),
/// недостающий регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру.
/// Запись при этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
/// </summary>
public sealed class BumperResolver(
IAppDbContext dbContext,
@@ -32,120 +26,175 @@ public sealed class BumperResolver(
IRandomSource random
)
{
public async Task<IReadOnlyDictionary<BumperKey, Guid>> ResolveAsync(
/// <summary>Ассеты заставок по индексу записи в ленте: одна и та же пара шоу может дать разный текст.</summary>
public async Task<IReadOnlyDictionary<int, Guid>> ResolveAsync(
Channel channel,
IReadOnlyList<PlannedItem> items,
CancellationToken cancellationToken
)
{
var reserved = items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList();
var reserved = items
.Select((item, index) => (item, index))
.Where(pair => pair.item.Kind == PlannedItemKind.Bumper)
.ToList();
if (reserved.Count == 0)
return new Dictionary<BumperKey, Guid>();
return new Dictionary<int, Guid>();
var showNames = await LoadShowNamesAsync(reserved, cancellationToken);
var result = new Dictionary<BumperKey, Guid>();
var templates = await LoadTemplatesAsync(reserved, cancellationToken);
if (templates.Count == 0)
return new Dictionary<int, Guid>();
// Кэш существующих заставок канала: одна пара шоу встречается в горизонте многократно.
var existing = await dbContext
.BumperAssets.Where(b => b.ChannelId == channel.Id)
.ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
var tokens = BumperPlaceholders.TokensIn(
templates
.Values.SelectMany(t => t.Variants)
.SelectMany(v => v.Lines)
.Select(l => l.Text)
);
var facts = await BumperFacts.LoadAsync(
dbContext,
channel,
items,
tokens,
cancellationToken
);
var requests = new List<(int Index, BumperRequest Request)>();
foreach (var item in reserved)
foreach (var (item, index) in reserved)
{
if (
item.BumperTemplateId is not { } templateId
|| channel.FindBumperTemplate(templateId) is not { } template
|| !templates.TryGetValue(templateId, out var template)
)
continue;
var fromShowId = item.FromShowId ?? Guid.Empty;
var toShowId = item.ToShowId ?? Guid.Empty;
var variant = PickVariant(template, fromShowId != toShowId, channel, random);
var variant = PickVariant(template, item.BumperVariantId, fromShowId != toShowId);
if (variant is null)
continue;
var key = new BumperKey(templateId, variant.Id, fromShowId, toShowId);
if (result.ContainsKey(key))
continue;
var context = facts.Context(item, index);
var lines = variant
.Lines.OrderBy(l => l.Position)
.Select(l => new BumperRenderLine(
l.Style,
l.Color,
BumperPlaceholders.Resolve(l.Text, context)
))
.Where(l => !string.IsNullOrWhiteSpace(l.Text))
.ToList();
var fromName = showNames.GetValueOrDefault(fromShowId, "—");
var toName = showNames.GetValueOrDefault(toShowId, "—");
var signature = Signature(template, variant.Id, fromName, toName);
if (existing.TryGetValue(signature, out var assetId))
var posterShowId = variant.Background switch
{
result[key] = assetId;
BumperBackground.NextPoster when toShowId != Guid.Empty => toShowId,
BumperBackground.NowPoster when fromShowId != Guid.Empty => fromShowId,
_ => (Guid?)null,
};
var linesJson = BumperRenderedText.ToJson(lines);
requests.Add(
(
index,
new BumperRequest(
template,
variant.Id,
linesJson,
posterShowId,
Signature(template, variant.Id, linesJson, posterShowId)
)
)
);
}
return await MaterializeAsync(requests, cancellationToken);
}
/// <summary>Заводит недостающие ассеты и раздаёт готовые по записям ленты.</summary>
private async Task<IReadOnlyDictionary<int, Guid>> MaterializeAsync(
IReadOnlyList<(int Index, BumperRequest Request)> requests,
CancellationToken cancellationToken
)
{
var signatures = requests.Select(r => r.Request.Signature).Distinct().ToList();
var existing = await dbContext
.BumperAssets.Where(b => signatures.Contains(b.Signature))
.ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
var result = new Dictionary<int, Guid>();
foreach (var (index, request) in requests)
{
if (existing.TryGetValue(request.Signature, out var assetId))
{
result[index] = assetId;
continue;
}
var asset = MediaAsset.RegisterGenerated($"{template.Name}: {fromName} → {toName}");
var asset = MediaAsset.RegisterGenerated(request.Caption);
dbContext.MediaAssets.Add(asset);
dbContext.BumperAssets.Add(
BumperAsset.Create(
channel.Id,
templateId,
variant.Id,
fromShowId,
toShowId,
signature,
request.Template.Id,
request.VariantId,
request.Signature,
request.Lines,
request.PosterShowId,
asset.Id
)
);
existing[signature] = asset.Id;
result[key] = asset.Id;
existing[request.Signature] = asset.Id;
result[index] = asset.Id;
renderQueue.Enqueue(asset.Id);
}
return result;
}
/// <summary>
/// Подблок, подходящий под контекст перехода: на смене шоу и между сериями одного играют разные
/// тексты. Стратегия выбора — общая настройка канала.
/// </summary>
private static BumperTextVariant? PickVariant(
BumperTemplate template,
bool isShowChange,
Channel channel,
IRandomSource random
private async Task<Dictionary<Guid, BumperTemplate>> LoadTemplatesAsync(
IReadOnlyList<(PlannedItem Item, int Index)> reserved,
CancellationToken cancellationToken
)
{
var eligible = template
.Variants.Where(v =>
v.Trigger switch
{
BumperTrigger.OnShowChange => isShowChange,
BumperTrigger.BetweenEpisodes => !isShowChange,
_ => true,
}
)
.OrderBy(v => v.Position)
var ids = reserved
.Select(r => r.Item.BumperTemplateId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
return await dbContext
.BumperTemplates.AsNoTracking()
.Include(t => t.Variants)
.Where(t => ids.Contains(t.Id))
.ToDictionaryAsync(t => t.Id, cancellationToken);
}
/// <summary>
/// Подблок: либо жёстко заданный врезкой, либо подходящий под контекст перехода — на смене шоу
/// и между сериями одного играют разные тексты. Среди подходящих выбор по весам.
/// </summary>
private BumperTextVariant? PickVariant(
BumperTemplate template,
Guid? fixedVariantId,
bool isShowChange
)
{
if (fixedVariantId is { } id)
return template.FindVariant(id);
var eligible = template
.Variants.Where(v => v.Matches(isShowChange))
.OrderBy(v => v.Position)
.ToList();
if (eligible.Count == 0)
return null;
return channel.BumperSelection switch
{
BumperSelection.AlwaysFirst => eligible[0],
BumperSelection.Random => eligible[random.Next(eligible.Count)],
BumperSelection.WeightedRandom => WeightedPick(eligible, random),
_ => eligible[random.Next(eligible.Count)],
};
}
private static BumperTextVariant WeightedPick(
IReadOnlyList<BumperTextVariant> eligible,
IRandomSource random
)
{
var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
var total = eligible.Sum(v => 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 accumulated = 0;
var roll = random.Next(total);
var accumulated = 0;
foreach (var variant in eligible)
{
accumulated += Math.Max(0, variant.Weight);
@@ -157,41 +206,44 @@ public sealed class BumperResolver(
}
/// <summary>
/// Сигнатура включает версию блока: замена звука или фона обязана пересобрать заставки, иначе
/// в эфире осталась бы старая картинка с новым оформлением рядом.
/// Сигнатура — хэш содержимого: оформление блока с его ревизией плюс подставленный текст. Канала
/// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а
/// <c>{channel}</c> в тексте разводит их сам собой.
/// </summary>
private static string Signature(
BumperTemplate template,
Guid variantId,
string fromName,
string toName
string linesJson,
Guid? posterShowId
)
{
var raw = string.Create(
CultureInfo.InvariantCulture,
$"{template.Id}|{variantId}|{template.Revision}|{fromName}|{toName}"
$"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{linesJson}"
);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32];
}
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
IReadOnlyList<PlannedItem> reserved,
CancellationToken cancellationToken
/// <summary>Что нужно отрендерить для одной записи ленты.</summary>
private sealed record BumperRequest(
BumperTemplate Template,
Guid VariantId,
string Lines,
Guid? PosterShowId,
string Signature
)
{
var showIds = reserved
.SelectMany(i => new[] { i.FromShowId, i.ToShowId })
.Where(id => id is not null && id != Guid.Empty)
.Select(id => id!.Value)
.Distinct()
.ToList();
if (showIds.Count == 0)
return [];
return await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
/// <summary>Имя ассета для админки: блок и первые строки заставки.</summary>
public string Caption
{
get
{
var text = string.Join(
" / ",
BumperRenderedText.FromJson(Lines).Select(l => l.Text).Take(2)
);
return text.Length == 0 ? Template.Name : $"{Template.Name}: {text}";
}
}
}
}
@@ -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>
@@ -5,22 +5,15 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
/// <summary>
/// Копирует сетку канала на другой канал: слои, слоты, стыки и правила. Группы не копируются —
/// они общие для всех каналов. Прежний шаблон канала-приёмника заменяется целиком.
/// Копирует сетку канала на другой канал: слои, слоты и правила. Группы, стыки и заставки
/// не копируются — они общие для всех каналов, копия ссылается на те же. Прежний шаблон
/// канала-приёмника заменяется целиком.
/// </summary>
public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId)
: ICommand<Result<CopyTemplateResultDto>>;
/// <summary>
/// Что скопировалось. <paramref name="DroppedBumperRefs"/> — врезки-заставки, для которых на канале
/// -приёмнике не нашлось блока с таким же именем: ссылка снята, врезку надо донастроить руками.
/// </summary>
public sealed record CopyTemplateResultDto(
int Layers,
int Slots,
int Junctions,
int DroppedBumperRefs
);
/// <summary>Что скопировалось.</summary>
public sealed record CopyTemplateResultDto(int Layers, int Slots);
public sealed class CopyTemplateCommandValidator : AbstractValidator<CopyTemplateCommand>
{
@@ -15,9 +15,10 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
CancellationToken cancellationToken
)
{
var target = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
var target = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.TargetChannelId,
cancellationToken
);
if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
@@ -30,138 +31,31 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var (junctionMap, droppedBumperRefs) = CopyJunctions(
sourceJunctions,
target.Id,
sourceBumperNames,
bumperByName
);
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext
.JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
)
.ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
if (
source.DefaultJunctionId is { } defaultJunction
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
)
copyTemplate.SetDefaultJunction(mappedDefault);
// Стыки и заставки общие для всех каналов — копия ссылается на те же, без перевешивания.
copyTemplate.SetDefaultJunction(source.DefaultJunctionId);
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
var (layers, slots) = CopyGrid(source, copyTemplate);
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
);
}
/// <summary>
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
/// </summary>
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
IReadOnlyList<JunctionTemplate> sourceJunctions,
Guid targetChannelId,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName
)
{
var map = new Dictionary<Guid, Guid>();
var dropped = 0;
foreach (var junction in sourceJunctions)
{
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
map[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position))
{
var bumperTemplateId = MapBumper(
element,
sourceBumperNames,
targetBumperByName,
ref dropped
);
copy.AddElement(element.Kind)
.Update(
element.Kind,
element.GroupId,
bumperTemplateId,
element.AmountMode,
element.AmountValue,
element.IsRequired,
element.ConditionsJson
);
}
dbContext.JunctionTemplates.Add(copy);
}
return (map, dropped);
}
/// <summary>
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
/// и это попадает в отчёт.
/// </summary>
private static Guid? MapBumper(
JunctionElement element,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName,
ref int dropped
)
{
if (element.Kind != JunctionElementKind.Bumper)
return null;
if (
element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name)
&& targetBumperByName.TryGetValue(name, out var mapped)
)
return mapped;
dropped++;
return null;
return Result.Success(new CopyTemplateResultDto(layers, slots));
}
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source,
ScheduleTemplate copyTemplate,
IReadOnlyDictionary<Guid, Guid> junctionMap
ScheduleTemplate copyTemplate
)
{
var layers = 0;
@@ -179,7 +73,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
foreach (var slot in layer.Slots)
{
CopySlot(slot, copyLayer, junctionMap);
CopySlot(slot, copyLayer);
slots++;
}
}
@@ -187,11 +81,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
return (layers, slots);
}
private static void CopySlot(
Slot slot,
GridLayer copyLayer,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
private static void CopySlot(Slot slot, GridLayer copyLayer)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
@@ -220,12 +110,9 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
slot.JunctionBetweenId,
slot.JunctionAfterId
)
);
}
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
@@ -11,7 +12,13 @@ public sealed record JunctionConditions(
/// <summary>Ставить только при смене шоу, а не между сериями одного.</summary>
bool OnlyOnElementChange = false,
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
int MinMinutesBetween = 0
int MinMinutesBetween = 0,
/// <summary>Только в эти дейпарты (пусто — в любые).</summary>
IReadOnlyList<Daypart>? Dayparts = null,
/// <summary>Только в это окно суток канала (null — в любое время).</summary>
JunctionTimeWindow? TimeWindow = null,
/// <summary>Вероятность показа в процентах; 100 — всегда.</summary>
int Chance = 100
)
{
private static readonly JsonSerializerOptions Options = new()
@@ -21,6 +28,10 @@ public sealed record JunctionConditions(
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Действует ли врезка в этом дейпарте.</summary>
public bool AllowsDaypart(Daypart daypart) =>
Dayparts is not { Count: > 0 } || Dayparts.Contains(daypart);
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static JunctionConditions? FromJson(string? json)
@@ -38,3 +49,6 @@ public sealed record JunctionConditions(
}
}
}
/// <summary>Окно суток канала; допускает переход через полночь («с 23:00 до 06:00»).</summary>
public sealed record JunctionTimeWindow(TimeOnly From, TimeOnly To);
@@ -21,7 +21,7 @@ public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
var element = junction.AddElement(command.Kind);
await JunctionLoader.MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
await JunctionLoader.MarkTemplatesChangedAsync(dbContext, junction.Id, cancellationToken);
return Result.Success(element.Id);
}
}
@@ -1,6 +1,4 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
@@ -10,16 +8,13 @@ namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
public Task<Result<Guid>> Handle(
CreateJunctionCommand command,
CancellationToken cancellationToken
)
{
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
return Result.Failure<Guid>(ChannelErrors.NotFound);
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
var junction = JunctionTemplate.Create(command.Name);
dbContext.JunctionTemplates.Add(junction);
return Result.Success(junction.Id);
return Task.FromResult(Result.Success(junction.Id));
}
}
@@ -21,19 +21,19 @@ public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
// Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
var used = await dbContext.Slots.AnyAsync(
// Стык общий: слот чужого канала, ссылающийся на удалённый стык, молча остался бы без врезок.
var usedBySlot = await dbContext.Slots.AnyAsync(
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
cancellationToken
);
if (used)
var usedByDefault = await dbContext.ScheduleTemplates.AnyAsync(
t => t.DefaultJunctionId == junction.Id,
cancellationToken
);
if (usedBySlot || usedByDefault)
return Result.Failure(TemplateErrors.JunctionInUse);
dbContext.JunctionTemplates.Remove(junction);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
return Result.Success();
}
}
@@ -5,12 +5,12 @@ using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed record ListJunctionsQuery(Guid ChannelId)
: IQuery<IReadOnlyList<JunctionTemplateDto>>;
public sealed record ListJunctionsQuery : IQuery<IReadOnlyList<JunctionTemplateDto>>;
public sealed record CreateJunctionCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
public sealed record CreateJunctionCommand(string Name) : ICommand<Result<Guid>>;
public sealed record RenameJunctionCommand(Guid JunctionId, string Name) : ICommand<Result>;
public sealed record UpdateJunctionCommand(Guid JunctionId, string Name, int? MaxTotalSeconds)
: ICommand<Result>;
public sealed record DeleteJunctionCommand(Guid JunctionId) : ICommand<Result>;
@@ -22,9 +22,13 @@ public sealed record JunctionElementInput(
JunctionElementKind Kind,
Guid? GroupId,
Guid? BumperTemplateId,
Guid? BumperVariantId,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
/// <summary>Метка развилки: из врезок с одной меткой играет одна, выбранная по весам.</summary>
string? ChoiceKey,
int ChoiceWeight,
JunctionConditions? Conditions
);
@@ -37,17 +41,32 @@ public sealed record UpdateJunctionElementCommand(
public sealed record RemoveJunctionElementCommand(Guid JunctionId, Guid ElementId)
: ICommand<Result>;
public sealed record ReorderJunctionCommand(Guid JunctionId, IReadOnlyList<Guid> ElementIdsInOrder)
: ICommand<Result>;
/// <summary>
/// Позиция врезки вместе с её развилкой: перетаскивание в цепочке одновременно меняет и порядок,
/// и принадлежность к развилке, поэтому отдельной команды «сгруппировать» нет.
/// </summary>
public sealed record JunctionElementOrder(Guid ElementId, string? ChoiceKey);
public sealed record ReorderJunctionCommand(
Guid JunctionId,
IReadOnlyList<JunctionElementOrder> Order
) : ICommand<Result>;
public sealed class CreateJunctionCommandValidator : AbstractValidator<CreateJunctionCommand>
{
public CreateJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
}
public sealed class RenameJunctionCommandValidator : AbstractValidator<RenameJunctionCommand>
public sealed class UpdateJunctionCommandValidator : AbstractValidator<UpdateJunctionCommand>
{
public RenameJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
public UpdateJunctionCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
// Потолок стыка в сутки — верхняя граница здравого смысла, а не техническое ограничение.
RuleFor(x => x.MaxTotalSeconds)
.InclusiveBetween(1, 24 * 60 * 60)
.When(x => x.MaxTotalSeconds is not null);
}
}
public sealed class UpdateJunctionElementCommandValidator
@@ -57,8 +76,13 @@ public sealed class UpdateJunctionElementCommandValidator
{
// Верхняя граница — сутки: врезка длиннее вещательного дня бессмысленна.
RuleFor(x => x.Input.AmountValue).InclusiveBetween(1, 24 * 60);
RuleFor(x => x.Input.ChoiceKey).MaximumLength(64);
RuleFor(x => x.Input.ChoiceWeight).InclusiveBetween(0, 1000);
RuleFor(x => x.Input.Conditions!.MinMinutesBetween)
.InclusiveBetween(0, 24 * 60)
.When(x => x.Input.Conditions is not null);
RuleFor(x => x.Input.Conditions!.Chance)
.InclusiveBetween(0, 100)
.When(x => x.Input.Conditions is not null);
}
}
@@ -10,14 +10,21 @@ public sealed record JunctionElementDto(
string? GroupName,
Guid? BumperTemplateId,
string? BumperTemplateName,
Guid? BumperVariantId,
string? BumperVariantName,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
string? ChoiceKey,
int ChoiceWeight,
JunctionConditions? Conditions
);
public sealed record JunctionTemplateDto(
Guid Id,
string Name,
int? MaxTotalSeconds,
/// <summary>Сколько каналов ссылается на стык — он общий, и это надо видеть до правки.</summary>
int ChannelUsageCount,
IReadOnlyList<JunctionElementDto> Elements
);
@@ -17,18 +17,37 @@ internal static class JunctionLoader
.JunctionTemplates.Include(j => j.Elements)
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
public static async Task<Result> MarkTemplateChangedAsync(
/// <summary>
/// Правка стыка — правка правил эфира. Стык общий, поэтому изменёнными помечаются все шаблоны,
/// которые на него ссылаются: иначе чужой канал молча поехал бы по новым врезкам без применения.
/// </summary>
public static async Task<Result> MarkTemplatesChangedAsync(
IAppDbContext dbContext,
JunctionTemplate junction,
Guid junctionId,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.ChannelId == junction.ChannelId,
cancellationToken
);
template?.MarkChanged();
var viaSlots = await dbContext
.Slots.AsNoTracking()
.Where(s => s.JunctionBetweenId == junctionId || s.JunctionAfterId == junctionId)
.Join(
dbContext.GridLayers.AsNoTracking(),
slot => slot.LayerId,
layer => layer.Id,
(_, layer) => layer.TemplateId
)
.Distinct()
.ToListAsync(cancellationToken);
var templates = await dbContext
.ScheduleTemplates.Where(t =>
viaSlots.Contains(t.Id) || t.DefaultJunctionId == junctionId
)
.ToListAsync(cancellationToken);
foreach (var template in templates)
template.MarkChanged();
return Result.Success();
}
}
@@ -15,53 +15,102 @@ public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == query.ChannelId)
.OrderBy(j => j.Name)
.ToListAsync(cancellationToken);
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
var groupIds = junctions
.SelectMany(j => j.Elements)
.Select(e => e.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
// Имена групп и заставок резолвим одним проходом — редактор показывает их сразу.
var groupIds = Ids(junctions, e => e.GroupId);
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var bumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == query.ChannelId)
.SelectMany(c => c.BumperTemplates)
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var templateIds = Ids(junctions, e => e.BumperTemplateId);
var bumpers = await dbContext
.BumperTemplates.AsNoTracking()
.Where(t => templateIds.Contains(t.Id))
.Include(t => t.Variants)
.ToListAsync(cancellationToken);
var bumperNames = bumpers.ToDictionary(t => t.Id, t => t.Name);
var variantNames = bumpers.SelectMany(t => t.Variants).ToDictionary(v => v.Id, v => v.Name);
var usage = await ChannelUsageAsync(cancellationToken);
return junctions
.Select(j => new JunctionTemplateDto(
j.Id,
j.Name,
j.MaxTotalSeconds,
usage.GetValueOrDefault(j.Id),
j.Elements.OrderBy(e => e.Position)
.Select(e => new JunctionElementDto(
e.Id,
e.Position,
e.Kind,
e.GroupId,
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
Lookup(groupNames, e.GroupId),
e.BumperTemplateId,
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
? bname
: null,
Lookup(bumperNames, e.BumperTemplateId),
e.BumperVariantId,
Lookup(variantNames, e.BumperVariantId),
e.AmountMode,
e.AmountValue,
e.IsRequired,
e.ChoiceKey,
e.ChoiceWeight,
JunctionConditions.FromJson(e.ConditionsJson)
))
.ToList()
))
.ToList();
}
/// <summary>Сколько каналов ссылается на каждый стык — слотами сетки либо стыком по умолчанию.</summary>
private async Task<Dictionary<Guid, int>> ChannelUsageAsync(CancellationToken cancellationToken)
{
var viaSlots = await (
from slot in dbContext.Slots.AsNoTracking()
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
join template in dbContext.ScheduleTemplates.AsNoTracking()
on layer.TemplateId equals template.Id
where slot.JunctionBetweenId != null || slot.JunctionAfterId != null
select new
{
template.ChannelId,
slot.JunctionBetweenId,
slot.JunctionAfterId,
}
).ToListAsync(cancellationToken);
var viaDefault = await dbContext
.ScheduleTemplates.AsNoTracking()
.Where(t => t.DefaultJunctionId != null)
.Select(t => new { t.ChannelId, t.DefaultJunctionId })
.ToListAsync(cancellationToken);
var pairs = viaSlots
.SelectMany(s =>
new[] { s.JunctionBetweenId, s.JunctionAfterId }
.Where(id => id is not null)
.Select(id => (JunctionId: id!.Value, s.ChannelId))
)
.Concat(viaDefault.Select(d => (JunctionId: d.DefaultJunctionId!.Value, d.ChannelId)));
return pairs.Distinct().GroupBy(p => p.JunctionId).ToDictionary(g => g.Key, g => g.Count());
}
private static List<Guid> Ids(
IEnumerable<Domain.Programming.JunctionTemplate> junctions,
Func<Domain.Programming.JunctionElement, Guid?> selector
) =>
junctions
.SelectMany(j => j.Elements)
.Select(selector)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
private static string? Lookup(IReadOnlyDictionary<Guid, string> names, Guid? id) =>
id is { } value && names.TryGetValue(value, out var name) ? name : null;
}
@@ -20,9 +20,9 @@ public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
if (junction is null || !junction.RemoveElement(command.ElementId))
return Result.Failure(TemplateErrors.JunctionElementNotFound);
return await JunctionLoader.MarkTemplateChangedAsync(
return await JunctionLoader.MarkTemplatesChangedAsync(
dbContext,
junction,
junction.Id,
cancellationToken
);
}
@@ -20,10 +20,15 @@ public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Reorder(command.ElementIdsInOrder);
return await JunctionLoader.MarkTemplateChangedAsync(
// Порядок и принадлежность к развилке приезжают вместе: в цепочке это один жест мышью.
foreach (var position in command.Order)
if (junction.FindElement(position.ElementId) is { } element)
element.SetChoice(position.ChoiceKey, element.ChoiceWeight);
junction.Reorder(command.Order.Select(o => o.ElementId));
return await JunctionLoader.MarkTemplatesChangedAsync(
dbContext,
junction,
junction.Id,
cancellationToken
);
}
@@ -4,11 +4,11 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RenameJunctionCommand, Result>
public sealed class UpdateJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateJunctionCommand, Result>
{
public async Task<Result> Handle(
RenameJunctionCommand command,
UpdateJunctionCommand command,
CancellationToken cancellationToken
)
{
@@ -20,10 +20,10 @@ public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Rename(command.Name);
return await JunctionLoader.MarkTemplateChangedAsync(
junction.Update(command.Name, command.MaxTotalSeconds);
return await JunctionLoader.MarkTemplatesChangedAsync(
dbContext,
junction,
junction.Id,
cancellationToken
);
}
@@ -1,6 +1,6 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
@@ -25,38 +25,60 @@ public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
return Result.Failure(TemplateErrors.JunctionElementNotFound);
var input = command.Input;
if (input.Kind == JunctionElementKind.Bumper)
{
var known = await dbContext
.Channels.Where(c => c.Id == junction.ChannelId)
.SelectMany(c => c.BumperTemplates)
.AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
if (!known)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
}
else
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.JunctionGroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
var check = await ValidateSourceAsync(input, cancellationToken);
if (!check.IsSuccess)
return check;
element.Update(
input.Kind,
input.GroupId,
input.BumperTemplateId,
input.AmountMode,
input.AmountValue,
input.IsRequired,
input.Conditions?.ToJson()
new JunctionElementSettings(
input.Kind,
input.GroupId,
input.BumperTemplateId,
input.BumperVariantId,
input.AmountMode,
input.AmountValue,
input.IsRequired,
input.ChoiceKey,
input.ChoiceWeight,
input.Conditions?.ToJson()
)
);
return await JunctionLoader.MarkTemplateChangedAsync(
return await JunctionLoader.MarkTemplatesChangedAsync(
dbContext,
junction,
junction.Id,
cancellationToken
);
}
/// <summary>Источник врезки должен существовать: молча пустая врезка выглядит как «стык не работает».</summary>
private async Task<Result> ValidateSourceAsync(
JunctionElementInput input,
CancellationToken cancellationToken
)
{
if (input.Kind != JunctionElementKind.Bumper)
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.JunctionGroupRequired);
return await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
? Result.Success()
: Result.Failure(TemplateErrors.GroupNotFound);
}
if (input.BumperTemplateId is not { } templateId)
return Result.Failure(BumperErrors.TemplateNotFound);
if (!await dbContext.BumperTemplates.AnyAsync(t => t.Id == templateId, cancellationToken))
return Result.Failure(BumperErrors.TemplateNotFound);
if (input.BumperVariantId is not { } variantId)
return Result.Success();
return await dbContext.BumperTextVariants.AnyAsync(
v => v.Id == variantId && v.BumperTemplateId == templateId,
cancellationToken
)
? Result.Success()
: Result.Failure(BumperErrors.VariantNotFound);
}
}