From fe11034b169b083f5a8c9befdea7f06ee8e25396 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 28 Jul 2026 02:23:43 +0300 Subject: [PATCH] Enhance grid import/export functionality to support dynamic groups and collections Updated the grid import/export process to allow for the inclusion of groups and collections directly from the configuration file. This enhancement ensures that missing groups and collections are created during import, improving the flexibility of the grid management system. Adjusted related classes and methods to accommodate these changes, ensuring a cohesive integration. Enhanced documentation and user prompts to clarify the new functionality and its usage. --- CLAUDE.md | 2 +- .../Programming/Templates/SlotWriter.cs | 7 +- .../Transfer/BuildGridPromptQueryHandler.cs | 11 +- .../Transfer/ExportGridQueryHandler.cs | 108 ++++++++++- .../Templates/Transfer/GridConfig.cs | 28 ++- .../Templates/Transfer/GridPromptText.cs | 24 ++- .../Templates/Transfer/ImportGridCommand.cs | 9 + .../Transfer/ImportGridCommandHandler.cs | 140 +++++++++++++- .../Programming/GridTransferTests.cs | 180 +++++++++++++++++- docs/tv-scheduler-architecture.md | 20 +- .../components/GridTransferDialog.tsx | 9 + frontend/src/shared/api/types.ts | 19 ++ frontend/src/shared/lib/locales/en.ts | 5 +- frontend/src/shared/lib/locales/ru.ts | 5 +- 14 files changed, 539 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 82c170c..7d30a0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,6 +155,6 @@ docker compose up -d --build # только api; postgres — внешний, правь его по месту. Но новые крупные направления с непринятыми архитектурными решениями (напр. многоэкземплярное развёртывание, транскод-профили, новые доменные подсистемы) начинай только после сверки с пользователем. При неоднозначности — вопрос пользователю, не предположение. -- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel. +- Соблюдай границы слоёв — главный инвариант проекта - Не коммить и не пуши без явной просьбы. - Отвечай пользователю на русском. diff --git a/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs index 853042c..333574f 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs @@ -38,7 +38,12 @@ public sealed class SlotWriter(IAppDbContext dbContext) if (input.GroupId is not { } groupId) return Result.Failure(TemplateErrors.GroupRequired); - if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)) + // Заведённая тут же группа ещё не в базе: импорт сетки приносит группы с собой и ставит + // слоты в том же прогоне, а запрос увидел бы их только после сохранения. + var known = + dbContext.Groups.Local.Any(g => g.Id == groupId) + || await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken); + if (!known) return Result.Failure(TemplateErrors.GroupNotFound); } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs index bb46b27..7696a29 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs @@ -19,7 +19,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa /// с ограниченным контекстом: группы для сетки важнее поимённого перечисления, поэтому список /// обрезается, и об обрезке прямо написано в запросе. /// - private const int ShowLimit = 150; + private const int ShowLimit = 300; private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU"); @@ -116,8 +116,9 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa } text.AppendLine( - "Имя группы в слотах должно совпадать с этим списком дословно. «Единиц» — сколько " - + "выходов группа даёт без повторов, «средняя» — длительность одной единицы." + "Эти группы уже собраны — их имена можно ставить в слоты как есть. «Единиц» — сколько " + + "выходов группа даёт без повторов, «средняя» — длительность одной единицы. " + + "Если нужного разреза библиотеки здесь нет, собери свою группу в разделе groups." ); text.AppendLine(); text.AppendLine("| Группа | Тип | Единиц | Средняя | Рейтинг | Жанр |"); @@ -149,8 +150,8 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa } text.AppendLine( - $"Всего готового к эфиру: {shows.Count}. Это справка о том, чем канал располагает; " - + "слоты всё равно ссылаются на группы, а не на шоу." + $"Всего готового к эфиру: {shows.Count}. Названия — ключ: собирая свои группы, пиши их " + + "дословно отсюда, иначе шоу не найдётся при загрузке." ); text.AppendLine(); text.AppendLine("| Шоу | Тип | Год | Единиц | Средняя | Рейтинг | Жанр |"); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs index 1db2ad3..3f365ca 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs @@ -3,12 +3,15 @@ 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.Programming; namespace TeleWave.Application.Programming.Templates.Transfer; -public sealed class ExportGridQueryHandler(IAppDbContext dbContext) - : IQueryHandler> +public sealed class ExportGridQueryHandler( + IAppDbContext dbContext, + DynamicGroupResolver dynamicResolver +) : IQueryHandler> { public async Task> Handle( ExportGridQuery query, @@ -54,6 +57,21 @@ public sealed class ExportGridQueryHandler(IAppDbContext dbContext) )) .ToList(); + // Состав групп едет вместе с сеткой: на другой установке тех же групп нет, а слот без + // группы поставить некуда. Собираем только использованные — выгружать всю библиотеку + // ради одного канала незачем. + var used = layers + .SelectMany(l => l.Slots) + .Select(s => s.Group) + .Append(Name(template.FallbackGroupId, groups)) + .OfType() + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var (exportedGroups, exportedCollections) = await ExportGroupsAsync( + used, + cancellationToken + ); + return Result.Success( new GridConfig( layers, @@ -64,11 +82,95 @@ public sealed class ExportGridQueryHandler(IAppDbContext dbContext) ), Rules: PlanningRules.FromJson(template.RulesJson), FallbackGroup: Name(template.FallbackGroupId, groups), - DefaultJunction: Name(template.DefaultJunctionId, junctions) + DefaultJunction: Name(template.DefaultJunctionId, junctions), + Collections: exportedCollections, + Groups: exportedGroups ) ); } + /// + /// Состав использованных групп и коллекций именами. Динамическая группа выгружается снимком + /// вычисленного состава, а не правилом: правило ссылается на жанры и рейтинги этой установки, + /// и на чужой переехало бы бессмыслицей. + /// + private async Task<( + List Groups, + List Collections + )> ExportGroupsAsync(IReadOnlySet used, CancellationToken cancellationToken) + { + var groups = await dbContext + .Groups.AsNoTracking() + .Include(g => g.Items) + .ToListAsync(cancellationToken); + + var showNames = await dbContext + .Shows.AsNoTracking() + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + var collectionNames = await dbContext + .Collections.AsNoTracking() + .ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken); + + var exported = new List(); + var collectionIds = new HashSet(); + + foreach (var group in groups.Where(g => used.Contains(g.Name.Trim()))) + { + var composition = await dynamicResolver.ResolveAsync(group, cancellationToken); + + var shows = composition + .Where(e => e.Kind == GroupElementKind.Show) + .Select(e => showNames.GetValueOrDefault(e.Id)) + .OfType() + .ToList(); + + var collections = new List(); + foreach (var element in composition.Where(e => e.Kind == GroupElementKind.Collection)) + { + if (collectionNames.GetValueOrDefault(element.Id) is not { } name) + continue; + collections.Add(name); + collectionIds.Add(element.Id); + } + + exported.Add( + new GridConfigGroup( + group.Name, + shows.Count > 0 ? shows : null, + collections.Count > 0 ? collections : null, + group.Description + ) + ); + } + + var items = await dbContext + .CollectionItems.AsNoTracking() + .Where(i => collectionIds.Contains(i.CollectionId)) + .OrderBy(i => i.Position) + .ToListAsync(cancellationToken); + + var exportedCollections = await dbContext + .Collections.AsNoTracking() + .Where(c => collectionIds.Contains(c.Id)) + .ToListAsync(cancellationToken); + + return ( + exported, + [ + .. exportedCollections.Select(collection => new GridConfigCollection( + collection.Name, + [ + .. items + .Where(i => i.CollectionId == collection.Id) + .Select(i => showNames.GetValueOrDefault(i.ShowId)) + .OfType(), + ], + collection.Description + )), + ] + ); + } + private static GridConfigSlot ToConfig( Slot slot, IReadOnlyDictionary groups, diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridConfig.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridConfig.cs index 5b409a9..92610c5 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridConfig.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridConfig.cs @@ -44,6 +44,28 @@ public sealed record GridConfigLayer( LayerApplicability? Applicability = null ); +/// +/// Коллекция в формате обмена: франшиза или цикл, собранный из шоу по названиям. Нужна там, где +/// в слот ставится не одно шоу, а серия фильмов целиком. +/// +public sealed record GridConfigCollection( + string Name, + IReadOnlyList Shows, + string? Description = null +); + +/// +/// Группа в формате обмена: список шоу и коллекций по названиям. Слот ссылается на группу, поэтому +/// собрать сетку под библиотеку, где групп ещё нет, можно только вместе с ними — импорт заводит +/// недостающие сам. +/// +public sealed record GridConfigGroup( + string Name, + IReadOnlyList? Shows = null, + IReadOnlyList? Collections = null, + string? Description = null +); + /// Параметры канала — переносятся справочно: импорт их не применяет. public sealed record GridConfigChannel(string Name, TimeOnly DayStart, int UtcOffsetMinutes); @@ -61,7 +83,11 @@ public sealed record GridConfig( /// Имя аварийной группы — ею закрываются паузы между слотами. string? FallbackGroup = null, /// Имя стыка по умолчанию. - string? DefaultJunction = null + string? DefaultJunction = null, + /// Коллекции, которых может не быть в библиотеке: импорт заводит недостающие. + IReadOnlyList? Collections = null, + /// Группы, на которые ссылаются слоты. Существующие по имени не трогаются. + IReadOnlyList? Groups = null ) { public const string CurrentFormat = "telewave.grid"; diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs index 11efa65..ad165af 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs @@ -18,8 +18,17 @@ public static class GridPromptText - Сутки вещательные, а не календарные: они начинаются со времени dayStart и заканчиваются им же следующего дня. Ночь после полуночи принадлежит предыдущему дню. - Слот — это место в сетке, а не конкретная серия: что именно выйдет, выберет планировщик - из указанной группы. Поэтому группа задаётся именем, и имя должно быть точно из списка - доступных ниже. Придумывать новые группы, шоу и стыки нельзя. + из указанной группы. Поэтому группа задаётся именем. + - Группы можно собирать самому: перечисли их в разделе groups, набрав из шоу библиотеки — + «Вечерние сериалы», «Детское утро», «Кино 90-х». Тех, что уже есть в списке доступных + групп, заводить заново не надо: имя оттуда просто ставится в слот. Группы с новым именем + будут созданы при загрузке файла. + - Коллекция — это цикл или франшиза («Терминатор», «Пираты Карибского моря»); собирается + в разделе collections и потом целиком кладётся в группу. Нужна там, где в слот идёт серия + фильмов, а не отдельное кино. + - Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу + импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет. + - Стыки и заставки придумывать нельзя — только имена из списков ниже. - Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»). - weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай @@ -51,6 +60,17 @@ public static class GridPromptText }, "fallbackGroup": "имя группы", // чем закрывать паузы "defaultJunction": "имя стыка", // стык между блоками по умолчанию + "collections": [ // необязательно: циклы и франшизы из шоу + { "name": "Терминатор", "shows": ["Терминатор", "Терминатор 2"] } + ], + "groups": [ // необязательно: группы, которых ещё нет + { + "name": "Вечерние сериалы", + "shows": ["Симпсоны", "Футурама"], // названия дословно из библиотеки + "collections": ["Терминатор"], // целиком, если нужен весь цикл + "description": "чем канал занимает прайм" + } + ], "layers": [ { "name": "Основная сетка", diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommand.cs index 0c97635..11181e9 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommand.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommand.cs @@ -21,6 +21,9 @@ public sealed record GridImportResultDto( int Slots, int Skipped, int Removed, + /// Сколько групп и коллекций файл принёс с собой — существующие не пересоздаются. + int Groups, + int Collections, IReadOnlyList Warnings ); @@ -35,6 +38,12 @@ public sealed class ImportGridCommandValidator : AbstractValidator x.Config.Layers).NotEmpty(); // Потолок на объём файла: недельная сетка — это десятки слотов, а не десятки тысяч. RuleFor(x => x.Config.Layers.Count).LessThanOrEqualTo(64); + RuleFor(x => x.Config.Groups!.Count) + .LessThanOrEqualTo(256) + .When(x => x.Config.Groups is not null); + RuleFor(x => x.Config.Collections!.Count) + .LessThanOrEqualTo(256) + .When(x => x.Config.Collections is not null); RuleForEach(x => x.Config.Layers) .ChildRules(layer => { diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs index 08b92b7..1abe631 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs @@ -3,6 +3,8 @@ 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; @@ -11,10 +13,17 @@ namespace TeleWave.Application.Programming.Templates.Transfer; /// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя /// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось. /// +/// Недостающие группы и коллекции файл может принести с собой: сетку, собранную под ещё не +/// разобранную библиотеку, иначе некуда положить — слот ссылается на группу, а групп нет. Шоу при +/// этом не создаются никогда: их приносит загрузка медиа, и выдумать их импорт не может. +/// /// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым. /// -public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter writer) - : ICommandHandler> +public sealed class ImportGridCommandHandler( + IAppDbContext dbContext, + SlotWriter writer, + GroupStatsService stats +) : ICommandHandler> { public async Task> Handle( ImportGridCommand command, @@ -29,16 +38,22 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter if (template is null) return Result.Failure(ChannelErrors.TemplateNotFound); - var groups = await NamesAsync( - dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)), - cancellationToken - ); 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; @@ -79,11 +94,122 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter created, skipped, removed, + createdGroups, + collections.Created, warnings ) ); } + /// + /// Заводит коллекции файла, которых нет в библиотеке. Существующая по имени не трогается: имя — + /// это ключ узнаваемости, и дописывать в чужую коллекцию свои шоу импорт не вправе. + /// + 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)) + continue; + if (names.ContainsKey(configGroup.Name.Trim())) + continue; + + 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}» создана пустой — слоты по ней закроет аварийный фон." + ); + + dbContext.Groups.Add(group); + // Статистика — кэш для карточки группы. Коллекция, заведённая этим же импортом, ещё не + // сохранена и в счёт не попадёт; цифры сойдутся на первой правке состава. + await stats.RecomputeAsync(group, cancellationToken); + names[group.Name] = group.Id; + created++; + } + + return (names, created); + } + + /// Шоу по названиям. Одинаковые названия схлопываются в первое найденное. + private Task> ShowNamesAsync(CancellationToken cancellationToken) => + NamesAsync( + dbContext.Shows.AsNoTracking().Select(s => new NamedRef(s.Id, s.Name)), + cancellationToken + ); + /// Снимает все слоты шаблона и возвращает их число — режим замены. private static int Clear(ScheduleTemplate template) { @@ -214,7 +340,7 @@ public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter if (known.TryGetValue(name.Trim(), out var id)) return id; - var warning = $"{subject} «{name}» не найдена в библиотеке."; + var warning = $"{subject} «{name}»: в библиотеке такого нет."; if (!warnings.Contains(warning, StringComparer.Ordinal)) warnings.Add(warning); return null; diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs index 7a3fabe..7d7627a 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs @@ -95,7 +95,7 @@ public class GridTransferTests private static async Task ExportAsync(TestDb fixture, Guid channelId) { await using var db = fixture.New(); - var result = await new ExportGridQueryHandler(db).Handle( + var result = await new ExportGridQueryHandler(db, GroupServices.Dynamic(db)).Handle( new ExportGridQuery(channelId), CancellationToken.None ); @@ -111,7 +111,7 @@ public class GridTransferTests ) { await using var db = fixture.New(); - var result = await new ImportGridCommandHandler(db, new SlotWriter(db)).Handle( + var result = await new ImportGridCommandHandler(db, new SlotWriter(db), Stats(db)).Handle( new ImportGridCommand(channelId, config, replace), CancellationToken.None ); @@ -291,6 +291,179 @@ public class GridTransferTests Assert.True(stored.HasPendingChanges); } + [Fact] + public async Task Import_CreatesGroupsFromShows() + { + // Сетка, собранная ИИ под ещё не разобранную библиотеку: групп нет, и положить слоты + // некуда — значит файл приносит группы с собой. + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture, withSlot: false); + + var config = new GridConfig( + [ + new GridConfigLayer( + "Основная сетка", + 10, + [ + new GridConfigSlot( + "Вечерний блок", + new TimeOnly(20, 0), + 120, + Daypart.Prime, + Group: "Вечерние сериалы" + ), + ] + ), + ], + Groups: [new GridConfigGroup("Вечерние сериалы", Shows: ["Сериал"])] + ); + + var imported = await ImportAsync(fixture, channelId, config); + + Assert.Equal(1, imported.Groups); + Assert.Equal(1, imported.Slots); + Assert.Empty(imported.Warnings); + + await using var db = fixture.New(); + var group = await db + .Groups.Include(g => g.Items) + .FirstAsync(g => g.Name == "Вечерние сериалы", CancellationToken.None); + Assert.Single(group.Items); + // Статистика — кэш карточки группы, и пустой после создания она быть не должна. + Assert.Equal(1, group.UnitCount); + } + + [Fact] + public async Task Import_CreatesCollections_AndPutsThemIntoGroups() + { + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture, withSlot: false); + + var config = new GridConfig( + [ + new GridConfigLayer( + "Основная сетка", + 10, + [ + new GridConfigSlot( + "Кино", + new TimeOnly(20, 0), + 120, + Daypart.Prime, + Group: "Циклы" + ), + ] + ), + ], + Collections: [new GridConfigCollection("Франшиза", ["Сериал"])], + Groups: [new GridConfigGroup("Циклы", Collections: ["Франшиза"])] + ); + + var imported = await ImportAsync(fixture, channelId, config); + + Assert.Equal(1, imported.Collections); + Assert.Equal(1, imported.Groups); + Assert.Empty(imported.Warnings); + + await using var db = fixture.New(); + var collection = await db + .Collections.Include(c => c.Items) + .FirstAsync(c => c.Name == "Франшиза", CancellationToken.None); + Assert.Single(collection.Items); + + var group = await db + .Groups.Include(g => g.Items) + .FirstAsync(g => g.Name == "Циклы", CancellationToken.None); + var item = Assert.Single(group.Items); + Assert.Equal(GroupElementKind.Collection, item.ElementKind); + Assert.Equal(collection.Id, item.ElementId); + } + + [Fact] + public async Task Import_DoesNotTouchExistingGroup() + { + // Группа с этим именем уже стоит в слотах соседнего канала: переписать её состав чужим + // файлом значит молча перекроить чужой эфир. + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture, withSlot: false); + + var config = new GridConfig( + [new GridConfigLayer("Основная сетка", 10, [])], + Groups: [new GridConfigGroup("Сериалы", Shows: ["Сериал", "Сериал"])] + ); + + var imported = await ImportAsync(fixture, channelId, config); + + Assert.Equal(0, imported.Groups); + + await using var db = fixture.New(); + var group = await db + .Groups.Include(g => g.Items) + .FirstAsync(g => g.Name == "Сериалы", CancellationToken.None); + Assert.Single(group.Items); + } + + [Fact] + public async Task Import_WarnsAboutUnknownShow_AndEmptyGroup() + { + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture, withSlot: false); + + var config = new GridConfig( + [new GridConfigLayer("Основная сетка", 10, [])], + Groups: [new GridConfigGroup("Придуманное", Shows: ["Шоу, которого нет"])] + ); + + var imported = await ImportAsync(fixture, channelId, config); + + Assert.Equal(1, imported.Groups); + Assert.Contains( + imported.Warnings, + w => w.Contains("Шоу, которого нет", StringComparison.Ordinal) + ); + // Пустая группа — это дыра в эфире, и сказать об этом надо на импорте, а не после эфира. + Assert.Contains( + imported.Warnings, + w => w.Contains("создана пустой", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task Export_CarriesCompositionOfUsedGroups() + { + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture); + + var config = await ExportAsync(fixture, channelId); + + // Файл должен быть самодостаточным: на другой установке этих групп нет, а слот без группы + // положить некуда. + var group = Assert.Single(config.Groups ?? []); + Assert.Equal("Сериалы", group.Name); + Assert.Equal(["Сериал"], group.Shows); + } + + [Fact] + public async Task Prompt_ExplainsHowToBuildGroups() + { + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture); + + await using var db = fixture.New(); + var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle( + new BuildGridPromptQuery(channelId, [], null), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var prompt = result.Value.Prompt; + + // Модель должна знать и что группы можно собрать самой, и что названия шоу берутся дословно. + Assert.Contains("\"groups\"", prompt, StringComparison.Ordinal); + Assert.Contains("\"collections\"", prompt, StringComparison.Ordinal); + Assert.Contains("дословно", prompt, StringComparison.Ordinal); + } + [Fact] public async Task Prompt_ListsGroupsAndSchema() { @@ -375,4 +548,7 @@ public class GridTransferTests private static GroupCatalog Catalog(IAppDbContext db) => new(db, GroupServices.Dynamic(db), new GroupElementResolver(db)); + + private static GroupStatsService Stats(IAppDbContext db) => + new(db, new GroupElementResolver(db), GroupServices.Dynamic(db)); } diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 8074572..f763108 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -1083,14 +1083,30 @@ seed = hash(channelId, date, slotId, occurrenceInDay) «Основная сетка (2)». Режим «снести существующие слоты» работает как пересборка из 6.9. Эфир импорт не двигает: он помечает шаблон изменённым, а хвост пересобирает кнопка применения. +**Файл приносит группы с собой.** Слот ссылается на группу, а на чужой установке (и на своей, пока +библиотека не разобрана) групп нет — поэтому в файле есть разделы `groups` и `collections`, и +недостающее импорт заводит сам: группа из шоу по названиям, коллекция как цикл или франшиза, которая +потом кладётся в группу целиком. Порядок жёсткий: коллекции → группы → слоты, иначе слоту нечего +разрешать. **Шоу импорт не создаёт никогда** — их приносит загрузка медиа, и придуманное моделью +название останется замечанием, а не записью в библиотеке. Группа, у которой не нашлось ни одного +шоу, всё равно создаётся, но с замечанием: пустая группа — это дыра в эфире, и сказать об этом надо +на импорте. + +Существующая по имени группа **не переписывается**: та же группа стоит в слотах других каналов, +и молча перекроить её принесённым файлом значит поменять чужой эфир. Экспорт при этом выгружает +состав использованных групп именами, чтобы файл был самодостаточным; динамическая группа +выгружается снимком вычисленного состава — правило ссылается на жанры и рейтинги этой установки +и на чужую переехало бы бессмыслицей. + **Запрос к ИИ собирается на сервере и никуда не отправляется.** Ключей внешних сервисов проект не хранит, и заводить их ради одной кнопки не нужно: админ вводит референс-каналы («как у 2×2») и пожелания своими словами, получает готовый текст, копирует его в ту модель, которой пользуется, и приносит ответ назад вкладкой импорта. В запрос входят параметры канала, доступные группы с их ёмкостью и средней длиной единицы (те же -числа, по которым раскладывает автосборка, — считает их общий `GroupCatalog`), библиотека справкой, -имена стыков и заставок, правила раскладки и схема ответа. Схема лежит рядом с форматом +числа, по которым раскладывает автосборка, — считает их общий `GroupCatalog`), библиотека поимённо +(модель собирает из неё свои группы, поэтому названия нужны дословные), имена стыков и заставок, +правила раскладки и схема ответа. Схема лежит рядом с форматом (`GridPromptText` в одной папке с `GridConfig`), а не в шаблоне на фронте: разойдись она с импортом — ответ модели пришлось бы чинить руками. diff --git a/frontend/src/features/admin/channels/components/GridTransferDialog.tsx b/frontend/src/features/admin/channels/components/GridTransferDialog.tsx index 6e2a96e..2637f7b 100644 --- a/frontend/src/features/admin/channels/components/GridTransferDialog.tsx +++ b/frontend/src/features/admin/channels/components/GridTransferDialog.tsx @@ -90,6 +90,15 @@ export function GridTransferDialog({ skipped: result.skipped, }), ) + // Заведённые группы упоминаем отдельно: файл от модели меняет не только сетку, и узнать + // об этом из общего «создано слотов» нельзя. + if (result.groups > 0 || result.collections > 0) + toast.success( + t('admin.channels.transfer.importedLibrary', { + groups: result.groups, + collections: result.collections, + }), + ) onImported() }, onError: (error: unknown) => { diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 2018867..7e639c1 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -827,6 +827,21 @@ export type GridConfigLayer = { applicability?: LayerApplicability | null } +/** Коллекция в формате обмена: цикл или франшиза из шоу по названиям. */ +export type GridConfigCollection = { + name: string + shows: string[] + description?: string | null +} + +/** Группа в формате обмена: шоу и коллекции по названиям — импорт заводит недостающие. */ +export type GridConfigGroup = { + name: string + shows?: string[] | null + collections?: string[] | null + description?: string | null +} + export type GridConfig = { format: string version: number @@ -834,6 +849,8 @@ export type GridConfig = { rules?: PlanningRules | null fallbackGroup?: string | null defaultJunction?: string | null + collections?: GridConfigCollection[] | null + groups?: GridConfigGroup[] | null layers: GridConfigLayer[] } @@ -843,6 +860,8 @@ export type GridImportResultDto = { slots: number skipped: number removed: number + groups: number + collections: number warnings: string[] } diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index ac51224..333d21f 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -439,16 +439,17 @@ export const en = { exportAction: 'Download file', exported: 'File exported', importHint: - 'Load a file or paste JSON — a model answer, for example. Unknown references do not break the import: such slots are skipped and listed below.', + 'Load a file or paste JSON — a model answer, for example. The file may bring missing groups and collections with it; shows are never created — those come from media uploads. Unknown references do not break the import: such slots are skipped and listed below.', pickFile: 'Pick a file', replace: 'Remove existing slots', pastePlaceholder: 'Paste the grid configuration JSON', importAction: 'Import', imported: 'Slots created: {{slots}}, skipped: {{skipped}}', + importedLibrary: 'Groups created: {{groups}}, collections: {{collections}}', badJson: 'That does not look like JSON — check that the whole answer was copied.', warnings: 'What could not be resolved', aiHint: - 'We build the request: channel settings, your groups and library, your references and notes, plus the answer schema. The model returns JSON that loads on the import tab.', + 'We build the request: channel settings, your groups and library, your references and notes, plus the answer schema. The model may define the groups it needs — the import creates them.', references: 'Reference channels', referencesPlaceholder: 'Cartoon Network, Paramount Comedy', referencesHint: diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index bffb430..2d964fe 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -440,16 +440,17 @@ export const ru = { exportAction: 'Скачать файл', exported: 'Файл выгружен', importHint: - 'Загрузите файл или вставьте JSON — например ответ модели. Незнакомые ссылки не валят импорт: такие слоты пропускаются, и вы увидите их списком.', + 'Загрузите файл или вставьте JSON — например ответ модели. Недостающие группы и коллекции файл может принести с собой; шоу не создаются никогда — их приносит загрузка медиа. Незнакомые ссылки не валят импорт: такие слоты пропускаются, и вы увидите их списком.', pickFile: 'Выбрать файл', replace: 'Снести существующие слоты', pastePlaceholder: 'Вставьте JSON конфигурации сетки', importAction: 'Загрузить', imported: 'Создано слотов: {{slots}}, пропущено: {{skipped}}', + importedLibrary: 'Заведено групп: {{groups}}, коллекций: {{collections}}', badJson: 'Это не похоже на JSON — проверьте, что скопирован весь ответ целиком.', warnings: 'Что не удалось разобрать', aiHint: - 'Соберём запрос: параметры канала, ваши группы и библиотека, ваши референсы и пожелания, плюс схема ответа. Модель вернёт JSON, который загружается вкладкой импорта.', + 'Соберём запрос: параметры канала, ваши группы и библиотека, ваши референсы и пожелания, плюс схема ответа. Модель может собрать нужные ей группы сама — они заведутся при импорте.', references: 'Референс-каналы', referencesPlaceholder: '2×2, Paramount Comedy', referencesHint: