Files
TeleWave/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs
T
Leonid Pershin 926a20020f
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 43s
ci / tests (push) Successful in 3m38s
ci / sonar (push) Successful in 3m38s
Add InterstitialGroups service and enhance SlotWriter and related components for interstitial handling
Introduced the InterstitialGroups service to manage interstitial group logic, preventing their inclusion in slots and fallback groups. Updated the SlotWriter class to utilize this service, ensuring proper validation during slot creation and updates. Enhanced error handling for interstitial groups in the ImportGridCommandHandler and UpdateTemplateCommandHandler, providing warnings instead of failures when interstitials are detected. Updated related tests to verify the correct behavior of these changes, ensuring robust handling of interstitials in the scheduling process.
2026-07-30 12:48:51 +03:00

426 lines
17 KiB
C#

using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Transfer;
/// <summary>
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
///
/// Недостающие группы и коллекции файл может принести с собой: сетку, собранную под ещё не
/// разобранную библиотеку, иначе некуда положить — слот ссылается на группу, а групп нет. Шоу при
/// этом не создаются никогда: их приносит загрузка медиа, и выдумать их импорт не может.
///
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
/// </summary>
public sealed class ImportGridCommandHandler(
IAppDbContext dbContext,
SlotWriter writer,
GroupStatsService stats,
InterstitialGroups interstitials
) : ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
{
public async Task<Result<GridImportResultDto>> Handle(
ImportGridCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.ChannelId, cancellationToken);
if (template is null)
return Result.Failure<GridImportResultDto>(ChannelErrors.TemplateNotFound);
var junctions = await NamesAsync(
dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)),
cancellationToken
);
var warnings = new List<string>();
// Библиотеку наполняем до слотов: слот ссылается на группу по имени, и группы из файла
// обязаны существовать к моменту, когда до него дойдёт очередь.
var collections = await EnsureCollectionsAsync(command.Config, warnings, cancellationToken);
var (groups, createdGroups) = await EnsureGroupsAsync(
command.Config,
collections.Names,
warnings,
cancellationToken
);
var removed = command.Replace ? Clear(template) : 0;
var created = 0;
var skipped = 0;
foreach (var configLayer in command.Config.Layers)
{
var layer = EnsureLayer(template, configLayer);
var (added, dropped) = await WriteSlotsAsync(
layer,
configLayer,
groups,
junctions,
warnings,
cancellationToken
);
created += added;
skipped += dropped;
}
await ApplyAsync(template, command.Config, groups, junctions, warnings, cancellationToken);
template.MarkChanged();
return Result.Success(
new GridImportResultDto(
command.Config.Layers.Count,
created,
skipped,
removed,
createdGroups,
collections.Created,
warnings
)
);
}
/// <summary>
/// Пишет слоты одного слоя. Возвращает, сколько встало и сколько пропущено: слот, который
/// не прошёл проверки, замечанием и остаётся — валить весь файл из-за одной строки нельзя.
/// </summary>
private async Task<(int Added, int Dropped)> WriteSlotsAsync(
GridLayer layer,
GridConfigLayer configLayer,
IReadOnlyDictionary<string, Guid> groups,
IReadOnlyDictionary<string, Guid> junctions,
List<string> warnings,
CancellationToken cancellationToken
)
{
var added = 0;
var dropped = 0;
foreach (var configSlot in configLayer.Slots)
{
var input = ToInput(configSlot, groups, junctions, warnings);
if (input is null)
{
dropped++;
continue;
}
var applied = await writer.ApplyAsync(layer, null, input, cancellationToken);
if (applied.IsSuccess)
{
added++;
continue;
}
dropped++;
warnings.Add(
$"Слот «{configSlot.Title}» ({configSlot.Start:HH\\:mm}): {applied.Error.Message}"
);
}
return (added, dropped);
}
/// <summary>
/// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя —
/// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе.
/// </summary>
private async Task<(Dictionary<string, Guid> Names, int Created)> EnsureCollectionsAsync(
GridConfig config,
List<string> warnings,
CancellationToken cancellationToken
)
{
var names = await NamesAsync(
dbContext.Collections.AsNoTracking().Select(c => new NamedRef(c.Id, c.Name)),
cancellationToken
);
if (config.Collections is not { Count: > 0 } wanted)
return (names, 0);
var shows = await ShowNamesAsync(cancellationToken);
var created = 0;
foreach (var configCollection in wanted)
{
if (string.IsNullOrWhiteSpace(configCollection.Name))
continue;
if (names.ContainsKey(configCollection.Name.Trim()))
continue;
var collection = Collection.Create(configCollection.Name, configCollection.Description);
foreach (var showName in configCollection.Shows ?? [])
{
if (Resolve(showName, shows, "Шоу", warnings) is { } showId)
collection.AddShow(showId);
}
dbContext.Collections.Add(collection);
names[collection.Name] = collection.Id;
created++;
}
return (names, created);
}
/// <summary>
/// Заводит группы файла, которых нет в библиотеке, и возвращает справочник имён для слотов.
/// Существующая группа переиспользуется как есть: состав чужой группы импорт не переписывает,
/// иначе один принесённый файл молча перекроил бы и соседний канал, где та же группа стоит
/// в слотах.
/// </summary>
private async Task<(Dictionary<string, Guid> Names, int Created)> EnsureGroupsAsync(
GridConfig config,
IReadOnlyDictionary<string, Guid> collections,
List<string> warnings,
CancellationToken cancellationToken
)
{
var names = await NamesAsync(
dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)),
cancellationToken
);
if (config.Groups is not { Count: > 0 } wanted)
return (names, 0);
var shows = await ShowNamesAsync(cancellationToken);
var created = 0;
foreach (var configGroup in wanted)
{
if (
string.IsNullOrWhiteSpace(configGroup.Name)
|| names.ContainsKey(configGroup.Name.Trim())
)
continue;
var group = BuildGroup(configGroup, shows, collections, warnings);
dbContext.Groups.Add(group);
// Статистика — кэш для карточки группы. Коллекция, заведённая этим же импортом, ещё не
// сохранена и в счёт не попадёт; цифры сойдутся на первой правке состава.
await stats.RecomputeAsync(group, cancellationToken);
names[group.Name] = group.Id;
created++;
}
return (names, created);
}
/// <summary>
/// Собирает состав группы из имён файла: что нашлось — в позиции, чего нет — в замечания.
/// Группа, где не нашлось ничего, всё равно создаётся, но с оговоркой: пустая группа — это
/// дыра в эфире, и узнать о ней надо на импорте, а не после применения сетки.
/// </summary>
private static Group BuildGroup(
GridConfigGroup configGroup,
IReadOnlyDictionary<string, Guid> shows,
IReadOnlyDictionary<string, Guid> collections,
List<string> warnings
)
{
var group = Group.Create(configGroup.Name, configGroup.Description);
foreach (var showName in configGroup.Shows ?? [])
{
if (Resolve(showName, shows, "Шоу", warnings) is { } showId)
group.AddElement(GroupElementKind.Show, showId);
}
foreach (var collectionName in configGroup.Collections ?? [])
{
if (Resolve(collectionName, collections, "Коллекция", warnings) is { } id)
group.AddElement(GroupElementKind.Collection, id);
}
if (group.Items.Count == 0)
warnings.Add(
$"Группа «{group.Name}» создана пустой — слоты по ней закроет аварийный фон."
);
return group;
}
/// <summary>Шоу по названиям. Одинаковые названия схлопываются в первое найденное.</summary>
private Task<Dictionary<string, Guid>> ShowNamesAsync(CancellationToken cancellationToken) =>
NamesAsync(
dbContext.Shows.AsNoTracking().Select(s => new NamedRef(s.Id, s.Name)),
cancellationToken
);
/// <summary>Снимает все слоты шаблона и возвращает их число — режим замены.</summary>
private static int Clear(ScheduleTemplate template)
{
var removed = 0;
foreach (var layer in template.Layers)
{
// Идентификаторы снимаем заранее: удаление правит ту самую коллекцию, по которой идём.
var slotIds = layer.Slots.Select(s => s.Id).ToList();
foreach (var slotId in slotIds)
layer.RemoveSlot(slotId);
removed += slotIds.Count;
}
return removed;
}
/// <summary>
/// Слой под слоты файла. Существующий берётся по имени: повторный импорт того же файла должен
/// давать ту же сетку, а не «Основная сетка (2)». Фоновый слой по имени не ищется — он один
/// и создаётся вместе с шаблоном.
/// </summary>
private static GridLayer EnsureLayer(ScheduleTemplate template, GridConfigLayer configLayer)
{
if (configLayer.IsBackground && template.Background is { } background)
return background;
var existing = template.Layers.FirstOrDefault(l =>
!l.IsBackground
&& string.Equals(l.Name, configLayer.Name, StringComparison.OrdinalIgnoreCase)
);
if (existing is not null)
return existing;
var layer = template.AddLayer(configLayer.Name, configLayer.Priority);
layer.Update(
configLayer.Name,
configLayer.Priority,
configLayer.Applicability?.ToJson(),
configLayer.IsEnabled
);
return layer;
}
/// <summary>Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале.</summary>
private async Task ApplyAsync(
ScheduleTemplate template,
GridConfig config,
IReadOnlyDictionary<string, Guid> groups,
IReadOnlyDictionary<string, Guid> junctions,
List<string> warnings,
CancellationToken cancellationToken
)
{
if (config.Rules is { } rules)
template.SetRules(rules.ToJson());
if (
config.FallbackGroup is { Length: > 0 } fallback
&& Resolve(fallback, groups, "Группа", warnings) is { } groupId
)
{
// Аварийной группой из роликов файл превращает в рекламу каждую паузу эфира. Замечанием,
// а не отказом: остальная сетка из файла корректна, и терять её из-за одного поля нельзя.
if (await interstitials.IsInterstitialAsync(groupId, cancellationToken))
warnings.Add(
$"Аварийная группа «{fallback}» собрана из роликов — она не проставлена: "
+ "паузы эфира стали бы рекламой."
);
else
template.SetFallbackGroup(groupId);
}
if (
config.DefaultJunction is { Length: > 0 } junction
&& Resolve(junction, junctions, "Стык", warnings) is { } junctionId
)
template.SetDefaultJunction(junctionId);
}
private static SlotInput? ToInput(
GridConfigSlot slot,
IReadOnlyDictionary<string, Guid> groups,
IReadOnlyDictionary<string, Guid> junctions,
List<string> warnings
)
{
if (string.IsNullOrWhiteSpace(slot.Title) || slot.DurationMinutes <= 0)
{
warnings.Add($"Слот «{slot.Title}»: нет названия или длительности.");
return null;
}
Guid? groupId = null;
if (slot.Group is { Length: > 0 } groupName)
{
groupId = Resolve(groupName, groups, "Группа", warnings);
if (groupId is null)
return null;
}
else if (slot.Kind == SlotKind.Content)
{
warnings.Add($"Слот «{slot.Title}»: для содержательного слота нужна группа.");
return null;
}
return new SlotInput(
slot.Title,
slot.Weekday,
slot.Start,
slot.DurationMinutes,
slot.Daypart,
slot.Kind,
groupId,
slot.Strategy,
slot.Repeat,
slot.BlockMode,
slot.BlockValue,
slot.Overflow,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetween is { Length: > 0 } between
? Resolve(between, junctions, "Стык", warnings)
: null,
slot.JunctionAfter is { Length: > 0 } after
? Resolve(after, junctions, "Стык", warnings)
: null
);
}
private static Guid? Resolve(
string name,
IReadOnlyDictionary<string, Guid> known,
string subject,
List<string> warnings
)
{
if (known.TryGetValue(name.Trim(), out var id))
return id;
var warning = $"{subject} «{name}»: в библиотеке такого нет.";
if (!warnings.Contains(warning, StringComparer.Ordinal))
warnings.Add(warning);
return null;
}
/// <summary>Справочник «имя → идентификатор». Одинаковые имена схлопываются в первое найденное.</summary>
private static async Task<Dictionary<string, Guid>> NamesAsync(
IQueryable<NamedRef> source,
CancellationToken cancellationToken
)
{
var items = await source.ToListAsync(cancellationToken);
var map = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items)
map.TryAdd(item.Name.Trim(), item.Id);
return map;
}
private sealed record NamedRef(Guid Id, string Name);
}