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: