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; /// /// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя /// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось. /// /// Недостающие группы и коллекции файл может принести с собой: сетку, собранную под ещё не /// разобранную библиотеку, иначе некуда положить — слот ссылается на группу, а групп нет. Шоу при /// этом не создаются никогда: их приносит загрузка медиа, и выдумать их импорт не может. /// /// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым. /// public sealed class ImportGridCommandHandler( IAppDbContext dbContext, SlotWriter writer, GroupStatsService stats, InterstitialGroups interstitials ) : ICommandHandler> { public async Task> 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(ChannelErrors.TemplateNotFound); var junctions = await NamesAsync( dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)), cancellationToken ); var warnings = new List(); // Библиотеку наполняем до слотов: слот ссылается на группу по имени, и группы из файла // обязаны существовать к моменту, когда до него дойдёт очередь. 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 ) ); } /// /// Пишет слоты одного слоя. Возвращает, сколько встало и сколько пропущено: слот, который /// не прошёл проверки, замечанием и остаётся — валить весь файл из-за одной строки нельзя. /// private async Task<(int Added, int Dropped)> WriteSlotsAsync( GridLayer layer, GridConfigLayer configLayer, IReadOnlyDictionary groups, IReadOnlyDictionary junctions, List 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); } /// /// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя — /// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе. /// private async Task<(Dictionary Names, int Created)> EnsureCollectionsAsync( GridConfig config, List 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); } /// /// Заводит группы файла, которых нет в библиотеке, и возвращает справочник имён для слотов. /// Существующая группа переиспользуется как есть: состав чужой группы импорт не переписывает, /// иначе один принесённый файл молча перекроил бы и соседний канал, где та же группа стоит /// в слотах. /// private async Task<(Dictionary Names, int Created)> EnsureGroupsAsync( GridConfig config, IReadOnlyDictionary collections, List 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); } /// /// Собирает состав группы из имён файла: что нашлось — в позиции, чего нет — в замечания. /// Группа, где не нашлось ничего, всё равно создаётся, но с оговоркой: пустая группа — это /// дыра в эфире, и узнать о ней надо на импорте, а не после применения сетки. /// private static Group BuildGroup( GridConfigGroup configGroup, IReadOnlyDictionary shows, IReadOnlyDictionary collections, List 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; } /// Шоу по названиям. Одинаковые названия схлопываются в первое найденное. private Task> ShowNamesAsync(CancellationToken cancellationToken) => NamesAsync( dbContext.Shows.AsNoTracking().Select(s => new NamedRef(s.Id, s.Name)), cancellationToken ); /// Снимает все слоты шаблона и возвращает их число — режим замены. 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; } /// /// Слой под слоты файла. Существующий берётся по имени: повторный импорт того же файла должен /// давать ту же сетку, а не «Основная сетка (2)». Фоновый слой по имени не ищется — он один /// и создаётся вместе с шаблоном. /// 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; } /// Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале. private async Task ApplyAsync( ScheduleTemplate template, GridConfig config, IReadOnlyDictionary groups, IReadOnlyDictionary junctions, List 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 groups, IReadOnlyDictionary junctions, List 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 known, string subject, List 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; } /// Справочник «имя → идентификатор». Одинаковые имена схлопываются в первое найденное. private static async Task> NamesAsync( IQueryable source, CancellationToken cancellationToken ) { var items = await source.ToListAsync(cancellationToken); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in items) map.TryAdd(item.Name.Trim(), item.Id); return map; } private sealed record NamedRef(Guid Id, string Name); }