From f7e7b5f7c3cc65e7edb26b8002c8f89816de5386 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 28 Jul 2026 02:15:04 +0300 Subject: [PATCH] Implement template export/import endpoints and enhance grid generation logic Added new endpoints for exporting and importing grid configurations in TemplateEndpoints, allowing for better management of template data. Enhanced the GenerateGridCommandHandler to support seasonal layers in grid generation, improving scheduling accuracy during holiday periods. Updated related classes and records to accommodate these changes, ensuring a cohesive integration of new features. Improved documentation for clarity and maintainability. --- .../Endpoints/TemplateEndpoints.cs | 55 +++ .../Broadcast/Bumpers/BumperPlaceholders.cs | 39 +- .../RenderBumperPreviewCommandHandler.cs | 5 +- .../DependencyInjection.cs | 1 + .../Programming/Planning/BumperFacts.cs | 93 ++++- .../Planning/GridScheduleGenerator.cs | 34 +- .../Generate/GenerateGridCommandHandler.cs | 47 ++- .../Templates/Generate/GridPlan.cs | 3 + .../Templates/Generate/GridPlanner.cs | 204 ++++------ .../Templates/Generate/GridProfile.cs | 11 +- .../Templates/Generate/GridProfiles.cs | 108 ++++- .../Templates/Generate/GroupCatalog.cs | 127 ++++++ .../Programming/Templates/Generate/PlanRun.cs | 9 + .../Templates/JunctionConditions.cs | 4 +- .../Templates/Junctions/JunctionCommands.cs | 4 + .../Transfer/BuildGridPromptQuery.cs | 35 ++ .../Transfer/BuildGridPromptQueryHandler.cs | 264 ++++++++++++ .../Templates/Transfer/ExportGridQuery.cs | 10 + .../Transfer/ExportGridQueryHandler.cs | 100 +++++ .../Templates/Transfer/GridConfig.cs | 80 ++++ .../Templates/Transfer/GridPromptText.cs | 88 ++++ .../Templates/Transfer/ImportGridCommand.cs | 45 +++ .../Transfer/ImportGridCommandHandler.cs | 239 +++++++++++ .../Programming/Planning/JunctionFiller.cs | 26 ++ .../Programming/Planning/PlanningModels.cs | 24 +- .../Programming/Planning/SchedulePlanner.cs | 102 +++-- .../Programming/Planning/UnitRotation.cs | 30 ++ .../Broadcast/BumperPlaceholdersTests.cs | 21 +- .../Broadcast/BumperResolverTests.cs | 68 ++++ .../Programming/GenerateGridTests.cs | 163 +++++++- .../Programming/GridPlanPreviewTests.cs | 10 +- .../Programming/GridTransferTests.cs | 378 ++++++++++++++++++ .../Programming/JunctionFillerTests.cs | 56 ++- .../Programming/SchedulePlannerTests.cs | 76 +++- docs/tv-scheduler-architecture.md | 55 ++- .../features/admin/bumpers/placeholders.ts | 27 +- .../features/admin/channels/ChannelDetail.tsx | 1 + frontend/src/features/admin/channels/api.ts | 30 ++ .../admin/channels/components/GridTab.tsx | 20 +- .../components/GridTransferDialog.tsx | 305 ++++++++++++++ .../junctions/components/JunctionChain.tsx | 2 + .../components/JunctionElementDialog.tsx | 26 ++ frontend/src/shared/api/types.ts | 65 ++- frontend/src/shared/lib/locales/en.ts | 41 ++ frontend/src/shared/lib/locales/ru.ts | 40 ++ 45 files changed, 2929 insertions(+), 242 deletions(-) create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQuery.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQuery.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Transfer/GridConfig.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs create mode 100644 backend/src/TeleWave.Domain/Programming/Planning/UnitRotation.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs create mode 100644 frontend/src/features/admin/channels/components/GridTransferDialog.tsx diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs index abcca91..5c9aa10 100644 --- a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs @@ -12,6 +12,7 @@ using TeleWave.Application.Programming.Templates.Generate; using TeleWave.Application.Programming.Templates.GetTemplate; using TeleWave.Application.Programming.Templates.Layers; using TeleWave.Application.Programming.Templates.Restore; +using TeleWave.Application.Programming.Templates.Transfer; using TeleWave.Application.Programming.Templates.UpdateSlot; using TeleWave.Application.Programming.Templates.Validate; using TeleWave.Infrastructure.Identity; @@ -80,6 +81,18 @@ public static class TemplateEndpoints .MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid) .Produces(); + // Обмен конфигурацией: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ. + // Запрос никуда не отправляется — админ копирует его в свою модель и приносит ответ назад. + admin + .MapGet("/channels/{channelId:guid}/template/export", ExportGrid) + .Produces(); + admin + .MapPost("/channels/{channelId:guid}/template/import", ImportGrid) + .Produces(); + admin + .MapPost("/channels/{channelId:guid}/template/ai-prompt", BuildPrompt) + .Produces(); + admin .MapPost("/templates/{templateId:guid}/layers", CreateLayer) .Produces(StatusCodes.Status201Created); @@ -224,6 +237,44 @@ public static class TemplateEndpoints return result.ToHttpResult(); } + private static async Task ExportGrid( + Guid channelId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ExportGridQuery(channelId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ImportGrid( + Guid channelId, + ImportGridBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ImportGridCommand(channelId, body.Config, body.Replace), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task BuildPrompt( + Guid channelId, + GridPromptBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new BuildGridPromptQuery(channelId, body.References ?? [], body.Notes), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task CopyTemplate( Guid channelId, Guid targetChannelId, @@ -354,6 +405,10 @@ public sealed record UpdateTemplateBody( public sealed record GenerateGridBody(GridProfileKind Profile, GridGenerationMode Mode); +public sealed record ImportGridBody(GridConfig Config, bool Replace); + +public sealed record GridPromptBody(IReadOnlyList? References, string? Notes); + public sealed record CreateLayerBody(string Name, int Priority); public sealed record UpdateLayerBody( diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs index 76079d3..d66881e 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs @@ -20,9 +20,15 @@ public sealed record BumperContext( string? NextGenre = null, /// Во сколько начнётся следующая программа (время канала). TimeOnly? NextTime = null, - string? SlotTitle = null + string? SlotTitle = null, + /// Что и когда идёт вечером этих вещательных суток — для анонсов. + BumperAnnouncement? Tonight = null, + BumperAnnouncement? Tomorrow = null ); +/// Анонсируемая программа: что и во сколько. Null — анонсировать нечего, строка схлопнется. +public sealed record BumperAnnouncement(string Title, TimeOnly Time); + /// /// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30». /// @@ -43,6 +49,10 @@ public static partial class BumperPlaceholders "next.year", "next.genre", "next.time", + "tonight.title", + "tonight.time", + "tomorrow.title", + "tomorrow.time", "time", "date", "weekday", @@ -79,16 +89,33 @@ public static partial class BumperPlaceholders /// по краям убираются: «СЕЙЧАС — {now.title}» без текущей программы должно стать «СЕЙЧАС», /// а не «СЕЙЧАС —». /// - /// Висящие предлоги («ДАЛЕЕ В») при этом остаются: отличить предлог от слова в тексте, который - /// пишет человек на любом языке, нельзя, а угадывать в эфире — хуже, чем оставить как есть. + /// Строка, в которой не подставился ни один плейсхолдер, исчезает целиком: она и писалась ради + /// данных, а «ДАЛЕЕ В» без времени — это не подпись, а мусор в кадре. Строки без плейсхолдеров + /// это не касается: они и есть постоянный текст. /// public static string Resolve(string text, BumperContext context) { if (string.IsNullOrWhiteSpace(text)) return string.Empty; + var tokens = 0; + var filled = 0; var resolved = TokenPattern() - .Replace(text, match => Value(match.Groups[1].Value, context) ?? string.Empty); + .Replace( + text, + match => + { + tokens++; + var value = Value(match.Groups[1].Value, context); + if (!string.IsNullOrWhiteSpace(value)) + filled++; + return value ?? string.Empty; + } + ); + + if (tokens > 0 && filled == 0) + return string.Empty; + return ExtraSpaces().Replace(resolved, " ").Trim().Trim(DanglingSeparators).Trim(); } @@ -134,6 +161,10 @@ public static partial class BumperPlaceholders "next.year" => context.NextYear?.ToString(Culture), "next.genre" => context.NextGenre, "next.time" => context.NextTime?.ToString("HH:mm", Culture), + "tonight.title" => context.Tonight?.Title, + "tonight.time" => context.Tonight?.Time.ToString("HH:mm", Culture), + "tomorrow.title" => context.Tomorrow?.Title, + "tomorrow.time" => context.Tomorrow?.Time.ToString("HH:mm", Culture), "time" => TimeOnly .FromDateTime(context.LocalMoment.DateTime) .ToString("HH:mm", Culture), diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs index efca552..605a307 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs @@ -105,7 +105,10 @@ public sealed class RenderBumperPreviewCommandHandler( samples.NextYear, samples.NextGenre, TimeOnly.FromDateTime(moment.AddMinutes(30).DateTime), - samples.SlotTitle + samples.SlotTitle, + // Анонсы в превью показываются образцом: настоящий вечер известен только ленте. + new BumperAnnouncement(samples.NextTitle, new TimeOnly(20, 0)), + new BumperAnnouncement(samples.NowTitle, new TimeOnly(20, 0)) ); } diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index e979228..ef080bd 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -48,6 +48,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs index d997967..d5bc20c 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs @@ -3,6 +3,7 @@ using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Interfaces; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; using TeleWave.Domain.Programming.Planning; namespace TeleWave.Application.Programming.Planning; @@ -20,20 +21,20 @@ internal sealed class BumperFacts private readonly TimeSpan _offset; private readonly Channel _channel; private readonly IReadOnlyDictionary _shows; - private readonly IReadOnlyDictionary _slotTitles; + private readonly IReadOnlyDictionary _slots; private BumperFacts( IReadOnlyList items, Channel channel, IReadOnlyDictionary shows, - IReadOnlyDictionary slotTitles + IReadOnlyDictionary slots ) { _items = items; _channel = channel; _offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes); _shows = shows; - _slotTitles = slotTitles; + _slots = slots; } public static async Task LoadAsync( @@ -44,9 +45,17 @@ internal sealed class BumperFacts CancellationToken cancellationToken ) { + var announces = tokens.Any(token => + token.StartsWith("tonight.") || token.StartsWith("tomorrow.") + ); + + // Имена нужны соседям заставки, а при анонсах — и всем программам ленты: анонсировать + // можно то, что стоит через несколько часов, а не только следующее. var showIds = items - .Where(i => i.Kind == PlannedItemKind.Bumper) - .SelectMany(i => new[] { i.FromShowId, i.ToShowId }) + .Where(i => + i.Kind == PlannedItemKind.Bumper || (announces && i.Kind == PlannedItemKind.Program) + ) + .SelectMany(i => new[] { i.FromShowId, i.ToShowId, i.ShowId }) .Where(id => id is not null && id != Guid.Empty) .Select(id => id!.Value) .Distinct() @@ -57,11 +66,14 @@ internal sealed class BumperFacts ? [] : await LoadShowsAsync(dbContext, showIds, tokens, cancellationToken); - var slotTitles = tokens.Contains("slot") - ? await LoadSlotTitlesAsync(dbContext, items, cancellationToken) - : []; + // Слоты нужны и названию, и анонсам: «сегодня вечером» — это первая программа прайма, + // а что считать праймом, решает дейпарт слота, проставленный админом. + var needsSlots = tokens.Any(token => + token == "slot" || token.StartsWith("tonight.") || token.StartsWith("tomorrow.") + ); + var slots = needsSlots ? await LoadSlotsAsync(dbContext, items, cancellationToken) : []; - return new BumperFacts(items, channel, shows, slotTitles); + return new BumperFacts(items, channel, shows, slots); } /// Контекст одной заставки: соседи по ленте, время показа и данные канала. @@ -85,12 +97,58 @@ internal sealed class BumperFacts nextProgram is { } next ? TimeOnly.FromDateTime(next.StartsAtUtc.ToOffset(_offset).DateTime) : null, - item.SlotId is { } slotId && _slotTitles.TryGetValue(slotId, out var title) - ? title - : null + item.SlotId is { } slotId && _slots.TryGetValue(slotId, out var slot) + ? slot.Title + : null, + Announcement(index, item, BroadcastDate(item.StartsAtUtc)), + Announcement(index, item, BroadcastDate(item.StartsAtUtc).AddDays(1)) ); } + /// + /// Что анонсировать на указанные вещательные сутки: первая программа прайма, которая ещё + /// не началась. Анонс «сегодня в 20:00», сказанный в девять вечера, — хуже, чем его отсутствие, + /// поэтому смотрим только вперёд по ленте. + /// + private BumperAnnouncement? Announcement(int index, PlannedItem bumper, DateOnly date) + { + for (var i = index + 1; i < _items.Count; i++) + { + var item = _items[i]; + if (item.Kind != PlannedItemKind.Program || item.ShowId is not { } showId) + continue; + if (BroadcastDate(item.StartsAtUtc) != date) + continue; + if (item.SlotId is not { } slotId || _slots.GetValueOrDefault(slotId)?.IsPrime != true) + continue; + if (item.StartsAtUtc <= bumper.EndsAtUtc) + continue; + + if (_shows.GetValueOrDefault(showId) is not { } show) + continue; + + return new BumperAnnouncement( + show.Name, + TimeOnly.FromDateTime(item.StartsAtUtc.ToOffset(_offset).DateTime) + ); + } + + return null; + } + + /// + /// Вещательные сутки момента: ночной блок до относится + /// к предыдущему дню, иначе «сегодня вечером» в час ночи означало бы уже следующий вечер. + /// + private DateOnly BroadcastDate(DateTimeOffset momentUtc) + { + var local = momentUtc.ToOffset(_offset); + var date = DateOnly.FromDateTime(local.DateTime); + return TimeOnly.FromDateTime(local.DateTime) >= _channel.DayStartTime + ? date + : date.AddDays(-1); + } + /// Подпись серии соседней программы — только если это та же самая программа. private static string? EpisodeOf(ShowFact? show, PlannedItem? neighbour, Guid? showId) { @@ -195,7 +253,7 @@ internal sealed class BumperFacts ); } - private static async Task> LoadSlotTitlesAsync( + private static async Task> LoadSlotsAsync( IAppDbContext dbContext, IReadOnlyList items, CancellationToken cancellationToken @@ -211,9 +269,16 @@ internal sealed class BumperFacts return await dbContext .Slots.AsNoTracking() .Where(s => slotIds.Contains(s.Id)) - .ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken); + .ToDictionaryAsync( + s => s.Id, + s => new SlotFact(s.Title, s.Daypart == Daypart.Prime), + cancellationToken + ); } + /// Слот глазами заставки: как он называется и прайм ли это. + private sealed record SlotFact(string Title, bool IsPrime); + private sealed record ShowFact( string Name, int? Year, diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 78f42d3..837cee5 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -391,6 +391,15 @@ public sealed class GridScheduleGenerator( var fallback = await LoadFallbackUnitsAsync(channel, template, cancellationToken); + // Чем добивается шов до круглой отметки: ролики и анонсы стыка канала по умолчанию. + var padJunction = BuildJunction( + template.DefaultJunctionId, + junctions, + elementsByGroup, + bumperTemplates, + Daypart.Day + ); + return new PlanningInput( channel.Id, startUtc, @@ -399,10 +408,32 @@ public sealed class GridScheduleGenerator( fallback, _segmentSeconds, channel.UtcOffsetMinutes, - grid.Background.Select(s => planned[s]).ToList() + grid.Background.Select(s => planned[s]).ToList(), + PadUnits(padJunction), + padJunction?.MaxTotal ?? PlanningInput.DefaultMaxPad ); } + /// + /// Ролики и анонсы стыка, распрямлённые в плоский список для добора шва. Заставки сюда + /// не попадают: их ассет рендерится под конкретную пару соседей, а шов — это ровно то место, + /// где пары ещё нет. + /// + private static IReadOnlyList PadUnits(PlanningJunction? junction) => + junction is null + ? [] + : junction + .Elements.Where(e => e.Kind != JunctionElementKind.Bumper && e.Units.Count > 0) + .SelectMany(e => + e.Units.Select(unit => new PadUnit( + unit, + e.Kind == JunctionElementKind.Promo + ? PlannedItemKind.Promo + : PlannedItemKind.Ad + )) + ) + .ToList(); + /// Разворачивает шаблон стыка для планировщика, включая резерв под заставки. private PlanningJunction? BuildJunction( Guid? junctionId, @@ -443,6 +474,7 @@ public sealed class GridScheduleGenerator( conditions.TimeWindow is { } window ? new PlanningTimeWindow(window.From, window.To) : null, + conditions.NearHourMinutes, element.ChoiceKey, element.ChoiceWeight, element.BumperTemplateId, diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommandHandler.cs index 0a5f1dd..58311e6 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommandHandler.cs @@ -24,6 +24,9 @@ public sealed class GenerateGridCommandHandler( private const int MainPriority = 10; private const int WeekendPriority = 20; + /// Праздники перекрывают и будни, и выходные: 1 января — это не «обычное воскресенье». + private const int SeasonPriority = 30; + public async Task> Handle( GenerateGridCommand command, CancellationToken cancellationToken @@ -53,7 +56,7 @@ public sealed class GenerateGridCommandHandler( var created = 0; foreach (var slot in plan.Slots) { - var layer = EnsureLayer(template, slot.Layer); + var layer = EnsureLayer(template, slot.Layer, plan.Profile.Season); var applied = await writer.ApplyAsync(layer, null, ToInput(slot), cancellationToken); if (!applied.IsSuccess) return Result.Failure(applied.Error); @@ -86,17 +89,38 @@ public sealed class GenerateGridCommandHandler( return removed; } - private static GridLayer EnsureLayer(ScheduleTemplate template, GridPlanLayer kind) + /// + /// Слой под слоты плана. Существующий переиспользуется по имени, новый — заводится; применимость + /// проставляется только новому: даты праздничного слоя админ мог подправить под свой канал, + /// и повторная генерация не должна их затирать. + /// + private static GridLayer EnsureLayer( + ScheduleTemplate template, + GridPlanLayer kind, + GridSeason? season + ) { - var (name, priority) = - kind == GridPlanLayer.Weekend - ? (GridPlanner.WeekendLayerName, WeekendPriority) - : (GridPlanner.MainLayerName, MainPriority); + var (name, priority) = kind switch + { + GridPlanLayer.Weekend => (GridPlanner.WeekendLayerName, WeekendPriority), + GridPlanLayer.Season => (season?.Name ?? "Праздники", SeasonPriority), + _ => (GridPlanner.MainLayerName, MainPriority), + }; - return template.Layers.FirstOrDefault(l => !l.IsBackground && l.Name == name) - ?? template.AddLayer(name, priority); + if (template.Layers.FirstOrDefault(l => !l.IsBackground && l.Name == name) is { } existing) + return existing; + + var layer = template.AddLayer(name, priority); + if (kind == GridPlanLayer.Season && season is not null) + layer.Update(name, priority, Applicability(season).ToJson(), isEnabled: true); + + return layer; } + /// Когда действует праздничный слой — ежегодный период профиля. + private static LayerApplicability Applicability(GridSeason season) => + new(AnnualRanges: [season.Range]); + private static SlotInput ToInput(PlannedSlot slot) => new( slot.Title, @@ -113,6 +137,11 @@ public sealed class GenerateGridCommandHandler( OverflowPolicy.ContinueNext, slot.IsAnchor, Slot.DefaultMaxDriftMinutes, - null + // Округление старта до четверти часа: программа передач не начинается в 19:47, а разницу + // добирает рекламный шов. Мягкое — если добирать пришлось бы дольше допуска, пропустится. + SnapMinutes ); + + /// Шаг округления стартов у собранных слотов — та же решётка, что и у длин. + private const int SnapMinutes = 15; } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs index cd5ce09..606ee2c 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs @@ -17,6 +17,9 @@ public enum GridPlanLayer { Main = 0, Weekend = 1, + + /// Праздничная сетка: слой поверх основного, действующий только в свои даты. + Season = 2, } /// diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs index 8e2d666..17659b7 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs @@ -21,11 +21,7 @@ namespace TeleWave.Application.Programming.Templates.Generate; /// на настоящем ТВ; жанр не занимает подряд две полосы. Подбор при этом остаётся объяснимым — /// жёсткий отсев по рейтингу, дальше баллы. /// -public sealed class GridPlanner( - IAppDbContext dbContext, - DynamicGroupResolver dynamicResolver, - GroupElementResolver elementResolver -) +public sealed class GridPlanner(IAppDbContext dbContext, GroupCatalog groups) { public const string MainLayerName = "Основная сетка"; public const string WeekendLayerName = "Выходные"; @@ -34,7 +30,19 @@ public sealed class GridPlanner( private const int MinSlotMinutes = 15; /// Длины слотов округляются до пяти минут: сетка должна читаться, а не считаться. - private const int SlotStepMinutes = 5; + /// + /// Шаг решётки времени. Программа передач состоит из круглых времён — 19:45, 20:00, 20:30, — + /// и именно это подсознательно отличает телеканал от выгрузки плейлиста. Длина слота округляется + /// вверх до шага, а разницу между контентом и отметкой съедает рекламный шов. + /// + private const int SlotStepMinutes = 15; + + /// + /// Шаг округления средней длины серии. Мельче решётки времени намеренно: это оценка контента, + /// а не отметка в программе, и округлив 25-минутную серию до получаса, мы отдали бы шву + /// пять лишних минут на каждой. + /// + private const int UnitStepMinutes = 5; /// Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один. private const int FeatureLengthMinutes = 60; @@ -64,14 +72,14 @@ public sealed class GridPlanner( if (template is null) return Result.Failure(ChannelErrors.TemplateNotFound); - var groups = await LoadGroupsAsync(cancellationToken); - if (groups.Count == 0) + var candidates = await groups.LoadAsync(cancellationToken); + if (candidates.Count == 0) return Result.Failure(TemplateErrors.NoGroupsToGenerate); var profile = GridProfiles.Get(profileKind); var run = new PlanRun( profile, - groups, + candidates, channel.DayStartTime, PlanningRules.FromJson(template.RulesJson) ); @@ -92,12 +100,20 @@ public sealed class GridPlanner( // Аварийная группа отдаётся отдельным полем, а не замечанием: замечания — про то, что пошло // не так, а это штатное действие, и в предпросмотре у него своя строка. - var fallback = groups.MaxBy(g => g.UnitCount); + var fallback = candidates.MaxBy(g => g.UnitCount); var needsFallback = template.FallbackGroupId is null && fallback is not null; var everyDaySpan = EveryDaySpan(mode, profile); var covered = slots.Sum(s => s.DurationMinutes * (s.Weekday is null ? everyDaySpan : 1)); + // Праздничный слой строится отдельным разбором: это не добавка к неделе, а замена ей на + // свои даты, и общий бюджет группы он тратить не должен — иначе обычная неделя обеднеет + // ради недели, которая идёт раз в год. + var season = + mode == GridGenerationMode.Rebuild && profile.Season is { } holidays + ? BuildSeason(holidays, run) + : []; + var notes = new List(run.Notes); notes.AddRange(run.WeeklyChecks()); notes.AddRange(AudienceChecks(slots, run)); @@ -114,7 +130,7 @@ public sealed class GridPlanner( new GridPlan( profile, mode, - slots, + [.. slots, .. season], notes, mode == GridGenerationMode.Rebuild ? existing.Count : 0, needsFallback ? fallback!.Id : null, @@ -125,6 +141,24 @@ public sealed class GridPlanner( ); } + /// + /// Раскладка праздничных суток. День строится один и ложится слотами «каждый день»: слой + /// действует несколько дат подряд, и расписывать их по дням недели значило бы получить сетку, + /// которая зависит от того, на какой день выпало 31 декабря. + /// + private static List BuildSeason(GridSeason season, PlanRun source) + { + var run = new PlanRun(source.Profile, source.Groups, source.DayStart, source.Rules); + run.BeginDay(); + + return FillDay( + season.Bands, + [(0, GridCoverage.MinutesInDay)], + new SlotTarget(null, GridPlanLayer.Season), + run + ); + } + /// Сколько дней в неделю действует слот «каждый день»: выходные могут быть перекрыты. private static int EveryDaySpan(GridGenerationMode mode, GridProfile profile) => mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7; @@ -387,9 +421,10 @@ public sealed class GridPlanner( return (Fit(Band.BlockMinutes > 0 ? Band.BlockMinutes : available, available), 0); var duration = Fit(Math.Max(1, Band.UnitsPerBlock) * unit, available); - // Число единиц — по фактической длине слота: полоса задаёт замысел, а сколько влезло, - // решает контент. - return (duration, Math.Max(1, (int)Math.Round(duration / (double)unit))); + // Число единиц — сколько их целиком влезает в слот: полоса задаёт замысел, а сколько + // поместилось, решает контент. Округление вверх до решётки оставляет хвост под рекламу, + // и брать за него ещё одну серию нельзя — она перелезет через следующую отметку. + return (duration, Math.Max(1, duration / unit)); } public PlannedSlot Content(GroupCandidate group, int available) @@ -474,12 +509,18 @@ public sealed class GridPlanner( private static int RepeatMinutes(GridBand band) => band.BlockMinutes > 0 ? band.BlockMinutes : 120; - private static int Round(int minutes) => - Math.Max(SlotStepMinutes, minutes / SlotStepMinutes * SlotStepMinutes); + /// + /// Вверх до шага решётки: слот не должен быть короче своего контента, иначе блок перелезет + /// через объявленное время. Разницу между контентом и отметкой занимает рекламный шов. + /// + private static int Round(int minutes, int step = SlotStepMinutes) => + Math.Max(step, (int)Math.Ceiling(minutes / (double)step) * step); /// Средняя длина единицы группы, округлённая до пяти минут (0 — длительностей нет). private static int UnitMinutes(GroupCandidate group) => - group.AverageUnitMinutes <= 0 ? 0 : Round((int)Math.Round(group.AverageUnitMinutes)); + group.AverageUnitMinutes <= 0 + ? 0 + : Round((int)Math.Round(group.AverageUnitMinutes), UnitStepMinutes); /// /// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной @@ -503,11 +544,18 @@ public sealed class GridPlanner( if (ranked.Count == 0) return null; - if (!band.Rotate || target.Weekday is not { } weekday) - return ranked[0]; - var day = Array.IndexOf(GridCoverage.Week, weekday); - return ranked[Math.Max(0, day) % ranked.Count]; + if (band.Rotate && target.Weekday is { } weekday) + { + // Ротация — это сдвиг по кругу, а не другой порядок: лучший кандидат остаётся лучшим, + // просто в понедельник он первый, во вторник второй. + var day = Math.Max(0, Array.IndexOf(GridCoverage.Week, weekday)); + ranked = [.. ranked.Skip(day % ranked.Count), .. ranked.Take(day % ranked.Count)]; + } + + // Та же группа встык допускается только если больше поставить нечего: балл её уже штрафует, + // но при двух кандидатах штраф проигрывает разнице в размере группы. + return ranked.FirstOrDefault(g => !run.FollowsSameGroup(g)) ?? ranked[0]; } private static bool Allowed(GridBand band, GroupCandidate group) @@ -546,6 +594,11 @@ public sealed class GridPlanner( if (run.FollowsSameGenre(group)) score -= 25; + // Та же группа встык — уже не ритм, а признак нехватки контента: штраф тяжелее жанрового, + // но не запрет: когда в библиотеке одна группа, ставить всё равно нечего. + if (run.FollowsSameGroup(group)) + score -= 90; + if (run.Rules?.MaxGenreSharePercent is { } maxShare && run.GenreShare(group) >= maxShare) score -= 60; @@ -585,113 +638,4 @@ public sealed class GridPlanner( ); } } - - private async Task> LoadGroupsAsync(CancellationToken cancellationToken) - { - var groups = await dbContext - .Groups.AsNoTracking() - .Include(g => g.Items) - .ToListAsync(cancellationToken); - - var candidates = new List(); - - foreach (var group in groups) - { - // Состав считается, а не читается из кэша: у группы по правилу кэш отстаёт от - // библиотеки, и генератор поставил бы в эфир опустевшую группу. - var composition = await dynamicResolver.ResolveAsync(group, cancellationToken); - if (composition.Count == 0) - continue; - - var info = await elementResolver.ResolveAsync( - composition.Select(e => (e.Kind, e.Id)), - cancellationToken - ); - var genreId = await PrimaryGenreAsync(composition, cancellationToken); - - if (Describe(group, composition, info, genreId) is { } candidate) - candidates.Add(candidate); - } - - return candidates; - } - - /// - /// Основной жанр группы — самый частый среди её шоу. Нужен, чтобы не ставить подряд две полосы - /// одного жанра и не выйти за потолок доли жанра из правил канала. - /// - private async Task PrimaryGenreAsync( - IReadOnlyList composition, - CancellationToken cancellationToken - ) - { - var showIds = composition - .Where(e => e.Kind == GroupElementKind.Show) - .Select(e => e.Id) - .ToList(); - var collectionIds = composition - .Where(e => e.Kind == GroupElementKind.Collection) - .Select(e => e.Id) - .ToList(); - - if (collectionIds.Count > 0) - { - var parts = await dbContext - .CollectionItems.AsNoTracking() - .Where(i => collectionIds.Contains(i.CollectionId)) - .Select(i => i.ShowId) - .ToListAsync(cancellationToken); - showIds.AddRange(parts); - } - - if (showIds.Count == 0) - return null; - - var genres = await dbContext - .ShowGenres.AsNoTracking() - .Where(g => showIds.Contains(g.ShowId) && g.IsPrimary) - .Select(g => g.GenreId) - .ToListAsync(cancellationToken); - - return genres.Count == 0 - ? null - : genres.GroupBy(id => id).OrderByDescending(g => g.Count()).First().Key; - } - - /// - /// Сводит вычисленный состав к тому, что нужно раскладке: сколько эфира группа даёт, какой она - /// в основном природы и насколько строгий в ней контент. Группа без готовых единиц не годится - /// вовсе — слот по ней встал бы, а место закрыл фон. - /// - private static GroupCandidate? Describe( - Group group, - IReadOnlyList composition, - IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo> info, - Guid? primaryGenreId - ) - { - var resolved = composition - .Select(e => info.GetValueOrDefault((e.Kind, e.Id))) - .OfType() - .ToList(); - - var units = resolved.Sum(r => r.UnitCount); - if (units == 0) - return null; - - // У коллекции своего типа нет: франшиза — это почти всегда полнометражки. - var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount); - var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration); - - return new GroupCandidate( - group.Id, - group.Name, - composition.Count, - units, - resolved.Max(r => r.Audience), - seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single, - duration.TotalMinutes / units, - primaryGenreId - ); - } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs index 3af7ef3..46c9921 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs @@ -54,6 +54,13 @@ public sealed record GridBand( } } +/// +/// Сезонный слой профиля: как выглядят сутки в праздничный период. Слой ложится поверх основного +/// и действует только в свои даты — на настоящем ТВ праздничная сетка ровно этим и является, +/// отдельной программой на неделю, а не правкой обычной. +/// +public sealed record GridSeason(string Name, AnnualRange Range, IReadOnlyList Bands); + /// /// Готовый профиль сетки: как выглядят вещательные сутки у канала такого типа. Профили списаны /// с реальных каналов намеренно — «как у 2×2» админ проверяет по памяти, а «универсальный @@ -67,5 +74,7 @@ public sealed record GridProfile( string Reference, string Description, IReadOnlyList Weekdays, - IReadOnlyList Weekend + IReadOnlyList Weekend, + /// Праздничная сетка профиля (null — сезонного слоя у профиля нет). + GridSeason? Season = null ); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs index 3358140..472d5dc 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs @@ -13,8 +13,20 @@ namespace TeleWave.Application.Programming.Templates.Generate; /// public static class GridProfiles { + // Праздничная сетка одна и та же у всех профилей, поэтому она навешивается здесь, а не + // повторяется шесть раз в описаниях: профиль отличают будни, а не Новый год. public static IReadOnlyList All { get; } = - [Mixed(), Animation(), Sitcom(), Music(), Movies(), Kids()]; + [.. Profiles().Select(profile => profile with { Season = Holidays() })]; + + private static IEnumerable Profiles() + { + yield return Mixed(); + yield return Animation(); + yield return Sitcom(); + yield return Music(); + yield return Movies(); + yield return Kids(); + } public static GridProfile Get(GridProfileKind kind) => All.FirstOrDefault(p => p.Kind == kind) ?? All[0]; @@ -48,6 +60,67 @@ public static class GridProfiles CooldownDays: 7 ); + /// + /// Праздничная сетка. Одна на все профили намеренно: в новогодние дни каналы сходятся к одному + /// и тому же — днём марафон, вечером кино, — и различает их библиотека, а не ритм суток. + /// + /// Период пересекает Новый год, поэтому он ежегодный, а не диапазон дат: заводить слой заново + /// каждый декабрь админ не должен. + /// + private static GridSeason Holidays() => + new( + "Праздничная неделя", + new AnnualRange(12, 25, 1, 8), + [ + new( + At(6), + At(10), + Daypart.Morning, + "Праздничное утро", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 120, + UnitsPerBlock: 4 + ), + new( + At(10), + At(16), + Daypart.Day, + "Праздничный марафон", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 180 + ), + new( + At(16), + At(19), + Daypart.Day, + "Семейное кино", + ShowKind.Single, + ShowAudience.Pg, + BlockMinutes: 150, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 21 + ), + new( + At(19), + At(23), + Daypart.Prime, + "Праздничный прайм", + ShowKind.Single, + ShowAudience.Pg13, + BlockMinutes: 150, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 21, + Rotate: true + ), + AdultNight(At(23), At(2)), + new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240), + ] + ); + private static GridProfile Mixed() => new( GridProfileKind.Mixed, @@ -84,7 +157,8 @@ public static class GridProfiles ShowKind.Series, ShowAudience.Pg13, BlockMinutes: 120, - UnitsPerBlock: 3 + UnitsPerBlock: 3, + Rotate: true ), new( At(18), @@ -196,7 +270,8 @@ public static class GridProfiles ShowKind.Series, ShowAudience.Pg13, BlockMinutes: 150, - UnitsPerBlock: 5 + UnitsPerBlock: 5, + Rotate: true ), new( At(19), @@ -251,8 +326,8 @@ public static class GridProfiles GridProfileKind.Sitcom, "Ситкомы", "как у Paramount Comedy", - "Полосы получасовых серий по 3–4 подряд весь день, комедийная полнометражка вечером, " - + "ночью — взрослый юмор.", + "Получасовые серии парами весь день — между парами рекламный шов, как в настоящем " + + "ситкомовском эфире. Вечером комедийная полнометражка, ночью взрослый юмор.", [ new( At(6), @@ -261,8 +336,8 @@ public static class GridProfiles "Утренние ситкомы", ShowKind.Series, ShowAudience.Pg13, - BlockMinutes: 90, - UnitsPerBlock: 3 + BlockMinutes: 60, + UnitsPerBlock: 2 ), new( At(12), @@ -271,8 +346,9 @@ public static class GridProfiles "Дневные ситкомы", ShowKind.Series, ShowAudience.Pg13, - BlockMinutes: 90, - UnitsPerBlock: 3 + BlockMinutes: 60, + UnitsPerBlock: 2, + Rotate: true ), new( At(18), @@ -281,8 +357,8 @@ public static class GridProfiles "Вечерние ситкомы", ShowKind.Series, ShowAudience.Pg13, - BlockMinutes: 120, - UnitsPerBlock: 4 + BlockMinutes: 60, + UnitsPerBlock: 2 ), new( At(22), @@ -315,8 +391,8 @@ public static class GridProfiles "Дневные ситкомы", ShowKind.Series, ShowAudience.Pg13, - BlockMinutes: 120, - UnitsPerBlock: 4 + BlockMinutes: 60, + UnitsPerBlock: 2 ), new( At(20), @@ -366,7 +442,8 @@ public static class GridProfiles DayBlock, BlockMinutes: 240, Strategy: SlotStrategyType.RandomWithCooldown, - CooldownDays: 2 + CooldownDays: 2, + Rotate: true ), new( At(18), @@ -506,7 +583,8 @@ public static class GridProfiles ShowKind.Series, ShowAudience.Pg, BlockMinutes: 90, - UnitsPerBlock: 3 + UnitsPerBlock: 3, + Rotate: true ), new( At(18), diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs new file mode 100644 index 0000000..7c963e3 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GroupCatalog.cs @@ -0,0 +1,127 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Generate; + +/// +/// Группы канала глазами раскладки: сколько эфира каждая даёт, какой она природы и насколько строгий +/// в ней контент. Отдельный тип, а не метод планировщика: те же факты нужны и запросу к ИИ, а считать +/// их дважды значит однажды разойтись — сетка собиралась бы по одним числам, а ИИ предлагал по другим. +/// +public sealed class GroupCatalog( + IAppDbContext dbContext, + DynamicGroupResolver dynamicResolver, + GroupElementResolver elementResolver +) +{ + public async Task> LoadAsync(CancellationToken cancellationToken) + { + var groups = await dbContext + .Groups.AsNoTracking() + .Include(g => g.Items) + .ToListAsync(cancellationToken); + + var candidates = new List(); + + foreach (var group in groups) + { + // Состав считается, а не читается из кэша: у группы по правилу кэш отстаёт от + // библиотеки, и генератор поставил бы в эфир опустевшую группу. + var composition = await dynamicResolver.ResolveAsync(group, cancellationToken); + if (composition.Count == 0) + continue; + + var info = await elementResolver.ResolveAsync( + composition.Select(e => (e.Kind, e.Id)), + cancellationToken + ); + var genreId = await PrimaryGenreAsync(composition, cancellationToken); + + if (Describe(group, composition, info, genreId) is { } candidate) + candidates.Add(candidate); + } + + return candidates; + } + + /// + /// Основной жанр группы — самый частый среди её шоу. Нужен, чтобы не ставить подряд две полосы + /// одного жанра и не выйти за потолок доли жанра из правил канала. + /// + private async Task PrimaryGenreAsync( + IReadOnlyList composition, + CancellationToken cancellationToken + ) + { + var showIds = composition + .Where(e => e.Kind == GroupElementKind.Show) + .Select(e => e.Id) + .ToList(); + var collectionIds = composition + .Where(e => e.Kind == GroupElementKind.Collection) + .Select(e => e.Id) + .ToList(); + + if (collectionIds.Count > 0) + { + var parts = await dbContext + .CollectionItems.AsNoTracking() + .Where(i => collectionIds.Contains(i.CollectionId)) + .Select(i => i.ShowId) + .ToListAsync(cancellationToken); + showIds.AddRange(parts); + } + + if (showIds.Count == 0) + return null; + + var genres = await dbContext + .ShowGenres.AsNoTracking() + .Where(g => showIds.Contains(g.ShowId) && g.IsPrimary) + .Select(g => g.GenreId) + .ToListAsync(cancellationToken); + + return genres.Count == 0 + ? null + : genres.GroupBy(id => id).OrderByDescending(g => g.Count()).First().Key; + } + + /// + /// Сводит вычисленный состав к тому, что нужно раскладке. Группа без готовых единиц не годится + /// вовсе — слот по ней встал бы, а место закрыл фон. + /// + private static GroupCandidate? Describe( + Group group, + IReadOnlyList composition, + IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo> info, + Guid? primaryGenreId + ) + { + var resolved = composition + .Select(e => info.GetValueOrDefault((e.Kind, e.Id))) + .OfType() + .ToList(); + + var units = resolved.Sum(r => r.UnitCount); + if (units == 0) + return null; + + // У коллекции своего типа нет: франшиза — это почти всегда полнометражки. + var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount); + var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration); + + return new GroupCandidate( + group.Id, + group.Name, + composition.Count, + units, + resolved.Max(r => r.Audience), + seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single, + duration.TotalMinutes / units, + primaryGenreId + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/PlanRun.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/PlanRun.cs index 550aafa..4690499 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/PlanRun.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/PlanRun.cs @@ -43,6 +43,7 @@ public sealed class PlanRun( private int _dayMinutes; private int _baselineMinutes; private Guid? _lastGenreId; + private Guid? _lastGroupId; private readonly List _notes = []; private readonly HashSet _seen = []; @@ -73,6 +74,7 @@ public sealed class PlanRun( _dayGenreMinutes = fromBaseline ? new Dictionary(_baselineGenre) : []; _dayMinutes = fromBaseline ? _baselineMinutes : 0; _lastGenreId = null; + _lastGroupId = null; } /// @@ -100,6 +102,12 @@ public sealed class PlanRun( public bool FollowsSameGenre(GroupCandidate group) => group.PrimaryGenreId is { } genreId && _lastGenreId == genreId; + /// + /// Та же группа, что и в предыдущем слоте. Два блока подряд из одной группы читаются как один + /// длинный: в сетке это выглядит не ритмом канала, а нехваткой контента. + /// + public bool FollowsSameGroup(GroupCandidate group) => _lastGroupId == group.Id; + // ── Неделя ─────────────────────────────────────────────────────────── /// Единиц группы, ещё не разобранных под слоты этой недели (может уйти в минус). @@ -116,6 +124,7 @@ public sealed class PlanRun( _weekAirings[group.Id] = _weekAirings.GetValueOrDefault(group.Id) + airingsPerWeek; _dayUsed[group.Id] = _dayUsed.GetValueOrDefault(group.Id) + 1; _dayMinutes += minutes; + _lastGroupId = group.Id; if (group.PrimaryGenreId is not { } genreId) return; diff --git a/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs b/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs index 11fbb6c..74b6d82 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/JunctionConditions.cs @@ -18,7 +18,9 @@ public sealed record JunctionConditions( /// Только в это окно суток канала (null — в любое время). JunctionTimeWindow? TimeWindow = null, /// Вероятность показа в процентах; 100 — всегда. - int Chance = 100 + int Chance = 100, + /// Ставить только в пределах ±N минут от круглого часа (0 — без привязки). + int NearHourMinutes = 0 ) { private static readonly JsonSerializerOptions Options = new() diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs index 16484f1..7b52090 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionCommands.cs @@ -84,5 +84,9 @@ public sealed class UpdateJunctionElementCommandValidator RuleFor(x => x.Input.Conditions!.Chance) .InclusiveBetween(0, 100) .When(x => x.Input.Conditions is not null); + // Допуск больше получаса перестаёт быть привязкой к часу: он покрывает час целиком. + RuleFor(x => x.Input.Conditions!.NearHourMinutes) + .InclusiveBetween(0, 30) + .When(x => x.Input.Conditions is not null); } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQuery.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQuery.cs new file mode 100644 index 0000000..c77d7be --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQuery.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Transfer; + +/// +/// Собирает текст запроса к ИИ: «вот канал, вот что у меня в библиотеке, вот на какие каналы +/// я равняюсь — верни сетку в этом формате». Запрос не отправляется никуда: админ копирует его +/// в ту модель, которой пользуется, и приносит ответ обратно кнопкой импорта. Ключей внешних +/// сервисов проект не хранит, и заводить их ради одной кнопки не нужно. +/// +/// Каналы-референсы: «как у 2×2», «как у Paramount Comedy». +/// Пожелания админа своими словами — попадают в запрос как есть. +public sealed record BuildGridPromptQuery( + Guid ChannelId, + IReadOnlyList References, + string? Notes +) : IQuery>; + +/// +/// Готовый запрос и то, из чего он собран: админ должен видеть, что модель получит библиотеку +/// целиком, а не первые десять шоу. +/// +public sealed record GridPromptDto(string Prompt, int Groups, int Shows, int Characters); + +public sealed class BuildGridPromptQueryValidator : AbstractValidator +{ + public BuildGridPromptQueryValidator() + { + RuleFor(x => x.References.Count).LessThanOrEqualTo(16); + RuleForEach(x => x.References).NotEmpty().MaximumLength(128); + RuleFor(x => x.Notes).MaximumLength(4000); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs new file mode 100644 index 0000000..bb46b27 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs @@ -0,0 +1,264 @@ +using System.Globalization; +using System.Text; +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Templates.Generate; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Programming.Templates.Transfer; + +public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCatalog catalog) + : IQueryHandler> +{ + /// + /// Потолок на список шоу. Библиотека канала может быть в тысячах серий, а запрос читает модель + /// с ограниченным контекстом: группы для сетки важнее поимённого перечисления, поэтому список + /// обрезается, и об обрезке прямо написано в запросе. + /// + private const int ShowLimit = 150; + + private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU"); + + public async Task> Handle( + BuildGridPromptQuery query, + CancellationToken cancellationToken + ) + { + var channel = await dbContext + .Channels.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var groups = await catalog.LoadAsync(cancellationToken); + var genres = await dbContext + .Genres.AsNoTracking() + .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); + var shows = await LoadShowsAsync(cancellationToken); + var junctions = await dbContext + .JunctionTemplates.AsNoTracking() + .OrderBy(j => j.Name) + .Select(j => j.Name) + .ToListAsync(cancellationToken); + var bumpers = await dbContext + .BumperTemplates.AsNoTracking() + .OrderBy(b => b.Name) + .Select(b => b.Name) + .ToListAsync(cancellationToken); + + var text = new StringBuilder(); + text.AppendLine(GridPromptText.Task).AppendLine(); + + text.AppendLine("## Канал"); + text.AppendLine( + Culture, + $"- Название: {channel.Name}\n" + + $"- Вещательные сутки начинаются в {channel.DayStartTime:HH\\:mm} (dayStart)\n" + + $"- Время канала: UTC{(channel.UtcOffsetMinutes < 0 ? "-" : "+")}{Math.Abs(channel.UtcOffsetMinutes) / 60}:{Math.Abs(channel.UtcOffsetMinutes) % 60:00}" + ); + + AppendReferences(text, query); + AppendGroups(text, groups, genres); + AppendShows(text, shows, genres); + AppendNames( + text, + "Стыки", + junctions, + "стыков ещё нет — поля junctionAfter/junctionBetween оставь пустыми" + ); + AppendNames(text, "Заставки", bumpers, "заставок ещё нет"); + + text.AppendLine().AppendLine(GridPromptText.Rules); + text.AppendLine().AppendLine(GridPromptText.Schema); + text.AppendLine().AppendLine(GridPromptText.Answer); + + var prompt = text.ToString(); + return Result.Success(new GridPromptDto(prompt, groups.Count, shows.Count, prompt.Length)); + } + + private static void AppendReferences(StringBuilder text, BuildGridPromptQuery query) + { + var references = query.References.Where(r => !string.IsNullOrWhiteSpace(r)).ToList(); + if (references.Count > 0) + { + text.AppendLine().AppendLine("## Референсы"); + text.AppendLine( + "Равняйся на ритм этих каналов — длину блоков, что стоит в прайме, чем закрыта ночь:" + ); + foreach (var reference in references) + text.AppendLine(Culture, $"- {reference.Trim()}"); + } + + if (string.IsNullOrWhiteSpace(query.Notes)) + return; + + text.AppendLine().AppendLine("## Пожелания"); + text.AppendLine(query.Notes.Trim()); + } + + private static void AppendGroups( + StringBuilder text, + IReadOnlyList groups, + IReadOnlyDictionary genres + ) + { + text.AppendLine().AppendLine("## Доступные группы"); + if (groups.Count == 0) + { + text.AppendLine( + "Готовых групп нет — сетку по ним собрать нельзя. Сначала собери группы в библиотеке." + ); + return; + } + + text.AppendLine( + "Имя группы в слотах должно совпадать с этим списком дословно. «Единиц» — сколько " + + "выходов группа даёт без повторов, «средняя» — длительность одной единицы." + ); + text.AppendLine(); + text.AppendLine("| Группа | Тип | Единиц | Средняя | Рейтинг | Жанр |"); + text.AppendLine("|---|---|---|---|---|---|"); + + foreach (var group in groups.OrderByDescending(g => g.UnitCount)) + { + var genre = group.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null; + text.AppendLine( + Culture, + $"| {group.Name} | {Kind(group.DominantKind)} | {group.UnitCount} | " + + $"{(int)Math.Round(group.AverageUnitMinutes)} мин | " + + $"{group.Strictest?.ToString() ?? "—"} | {genre ?? "—"} |" + ); + } + } + + private static void AppendShows( + StringBuilder text, + IReadOnlyList shows, + IReadOnlyDictionary genres + ) + { + text.AppendLine().AppendLine("## Библиотека"); + if (shows.Count == 0) + { + text.AppendLine("Библиотека пуста."); + return; + } + + text.AppendLine( + $"Всего готового к эфиру: {shows.Count}. Это справка о том, чем канал располагает; " + + "слоты всё равно ссылаются на группы, а не на шоу." + ); + text.AppendLine(); + text.AppendLine("| Шоу | Тип | Год | Единиц | Средняя | Рейтинг | Жанр |"); + text.AppendLine("|---|---|---|---|---|---|---|"); + + foreach (var show in shows.Take(ShowLimit)) + { + var genre = show.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null; + text.AppendLine( + Culture, + $"| {show.Name} | {Kind(show.Kind)} | {show.Year?.ToString(Culture) ?? "—"} | " + + $"{show.Units} | {show.AverageMinutes} мин | " + + $"{show.Audience?.ToString() ?? "—"} | {genre ?? "—"} |" + ); + } + + if (shows.Count > ShowLimit) + text.AppendLine( + Culture, + $"\n…и ещё {shows.Count - ShowLimit} — список обрезан, полный состав виден по группам выше." + ); + } + + private static void AppendNames( + StringBuilder text, + string title, + IReadOnlyList names, + string empty + ) + { + text.AppendLine().AppendLine(Culture, $"## {title}"); + text.AppendLine(names.Count == 0 ? empty : string.Join(", ", names)); + } + + private static string Kind(ShowKind kind) => + kind switch + { + ShowKind.Series => "сериал", + ShowKind.Interstitial => "ролик", + _ => "полный метр", + }; + + /// + /// Шоу, у которых есть что показывать. Считается по готовым медиа: шоу без обработанного файла + /// в эфир не пойдёт, и предлагать его модели значит получить сетку под несуществующий контент. + /// + private async Task> LoadShowsAsync(CancellationToken cancellationToken) + { + var shows = await dbContext + .Shows.AsNoTracking() + .Include(s => s.Episodes) + .ToListAsync(cancellationToken); + + var assetIds = shows + .SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)) + .Distinct() + .ToList(); + var ready = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready) + .ToDictionaryAsync(a => a.Id, a => a.Duration ?? TimeSpan.Zero, cancellationToken); + + var primaryGenres = await dbContext + .ShowGenres.AsNoTracking() + .Where(g => g.IsPrimary) + .ToDictionaryAsync(g => g.ShowId, g => g.GenreId, cancellationToken); + + var facts = new List(); + + foreach (var show in shows) + { + var durations = show + .Episodes.Select(e => + ready.TryGetValue(e.MediaAssetId, out var d) ? d : (TimeSpan?)null + ) + .OfType() + .ToList(); + if (durations.Count == 0) + continue; + + facts.Add( + new ShowFact( + show.Name, + show.Kind, + show.Year, + show.Audience, + durations.Count, + (int)Math.Round(durations.Average(d => d.TotalMinutes)), + primaryGenres.TryGetValue(show.Id, out var genreId) ? genreId : null + ) + ); + } + + return + [ + .. facts + .OrderByDescending(s => s.Units) + .ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase), + ]; + } + + private sealed record ShowFact( + string Name, + ShowKind Kind, + int? Year, + ShowAudience? Audience, + int Units, + int AverageMinutes, + Guid? PrimaryGenreId + ); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQuery.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQuery.cs new file mode 100644 index 0000000..a747d1f --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQuery.cs @@ -0,0 +1,10 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Transfer; + +/// +/// Выгрузка сетки канала одним файлом: перенос на другой канал, бэкап перед экспериментом +/// и образец для ИИ — один и тот же формат на все три случая. +/// +public sealed record ExportGridQuery(Guid ChannelId) : IQuery>; diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs new file mode 100644 index 0000000..1db2ad3 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ExportGridQueryHandler.cs @@ -0,0 +1,100 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Transfer; + +public sealed class ExportGridQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + ExportGridQuery query, + CancellationToken cancellationToken + ) + { + var channel = await dbContext + .Channels.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = await dbContext + .ScheduleTemplates.AsNoTracking() + .Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .AsSplitQuery() + .FirstOrDefaultAsync(t => t.ChannelId == query.ChannelId, cancellationToken); + if (template is null) + return Result.Failure(ChannelErrors.TemplateNotFound); + + var groups = await dbContext + .Groups.AsNoTracking() + .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); + var junctions = await dbContext + .JunctionTemplates.AsNoTracking() + .ToDictionaryAsync(j => j.Id, j => j.Name, cancellationToken); + + var layers = template + .Layers.OrderBy(l => l.Priority) + .Select(layer => new GridConfigLayer( + layer.Name, + layer.Priority, + [ + .. layer + .Slots.OrderBy(s => s.Weekday ?? -1) + .ThenBy(s => s.TargetStart) + .Select(slot => ToConfig(slot, groups, junctions)), + ], + layer.IsEnabled, + layer.IsBackground, + LayerApplicability.FromJson(layer.ApplicabilityJson) + )) + .ToList(); + + return Result.Success( + new GridConfig( + layers, + Channel: new GridConfigChannel( + channel.Name, + channel.DayStartTime, + channel.UtcOffsetMinutes + ), + Rules: PlanningRules.FromJson(template.RulesJson), + FallbackGroup: Name(template.FallbackGroupId, groups), + DefaultJunction: Name(template.DefaultJunctionId, junctions) + ) + ); + } + + private static GridConfigSlot ToConfig( + Slot slot, + IReadOnlyDictionary groups, + IReadOnlyDictionary junctions + ) => + new( + slot.Title, + slot.TargetStart, + slot.TargetDurationMinutes, + slot.Daypart, + slot.SlotKind, + slot.Weekday, + Name(slot.GroupId, groups), + SlotStrategy.FromJson(slot.StrategyJson), + RepeatSource.FromJson(slot.RepeatSourceJson), + slot.BlockMode, + slot.BlockValue, + slot.OverflowPolicy, + slot.IsAnchor, + slot.MaxDriftMinutes, + slot.SnapToMinutes, + Name(slot.JunctionBetweenId, junctions), + Name(slot.JunctionAfterId, junctions) + ); + + /// Имя по ссылке. Пропавшая ссылка выгружается пустой — файл важнее её сохранности. + private static string? Name(Guid? id, IReadOnlyDictionary names) => + id is { } value ? names.GetValueOrDefault(value) : null; +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridConfig.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridConfig.cs new file mode 100644 index 0000000..5b409a9 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridConfig.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Transfer; + +/// +/// Слот в формате обмена. Группы и стыки — **по именам**, а не по идентификаторам: файл переносится +/// между каналами и установками, где тех же GUID нет, а сетку для импорта пишет в том числе ИИ, +/// который идентификаторы может только выдумать. +/// +public sealed record GridConfigSlot( + string Title, + TimeOnly Start, + int DurationMinutes, + Daypart Daypart = Daypart.Day, + SlotKind Kind = SlotKind.Content, + /// 0=Вс..6=Сб; null — слот идёт каждый день. + int? Weekday = null, + /// Имя группы — обязательно для слотов вида Content. + string? Group = null, + SlotStrategy? Strategy = null, + RepeatSource? Repeat = null, + SlotBlockMode BlockMode = SlotBlockMode.FillSlot, + int BlockValue = 1, + OverflowPolicy Overflow = OverflowPolicy.ContinueNext, + bool IsAnchor = false, + int MaxDriftMinutes = Slot.DefaultMaxDriftMinutes, + int? SnapToMinutes = null, + /// Имя стыка между единицами блока. + string? JunctionBetween = null, + /// Имя стыка в конце блока; null — стык шаблона по умолчанию. + string? JunctionAfter = null +); + +/// Слой сетки в формате обмена. +public sealed record GridConfigLayer( + string Name, + int Priority, + IReadOnlyList Slots, + bool IsEnabled = true, + /// Фоновый слой один и создаётся вместе с шаблоном — при импорте в него только пишутся слоты. + bool IsBackground = false, + LayerApplicability? Applicability = null +); + +/// Параметры канала — переносятся справочно: импорт их не применяет. +public sealed record GridConfigChannel(string Name, TimeOnly DayStart, int UtcOffsetMinutes); + +/// +/// Конфигурация сетки канала одним файлом: слои, слоты, правила отбора и ссылки на группы и стыки +/// по именам. Формат один и тот же на выгрузку, загрузку и на то, что просят сгенерировать ИИ, — +/// иначе схема в запросе однажды разойдётся с тем, что читает импорт. +/// +public sealed record GridConfig( + IReadOnlyList Layers, + string Format = GridConfig.CurrentFormat, + int Version = GridConfig.CurrentVersion, + GridConfigChannel? Channel = null, + PlanningRules? Rules = null, + /// Имя аварийной группы — ею закрываются паузы между слотами. + string? FallbackGroup = null, + /// Имя стыка по умолчанию. + string? DefaultJunction = null +) +{ + public const string CurrentFormat = "telewave.grid"; + public const int CurrentVersion = 1; + + public static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + WriteIndented = true, + }; + + public string ToJson() => JsonSerializer.Serialize(this, Options); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs new file mode 100644 index 0000000..11efa65 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs @@ -0,0 +1,88 @@ +namespace TeleWave.Application.Programming.Templates.Transfer; + +/// +/// Постоянная часть запроса к ИИ: задача, правила и схема ответа. Схема описывает ровно то, что +/// читает , — держать её рядом с форматом, а не в шаблоне +/// на фронте, значит не разойтись с импортом при первой же правке полей. +/// +public static class GridPromptText +{ + public const string Task = """ + Ты — программный директор телеканала. Собери недельную сетку вещания и верни её одним + JSON-документом описанного ниже формата. + """; + + public const string Rules = """ + Правила, по которым сетку потом разложит планировщик: + + - Сутки вещательные, а не календарные: они начинаются со времени dayStart и заканчиваются + им же следующего дня. Ночь после полуночи принадлежит предыдущему дню. + - Слот — это место в сетке, а не конкретная серия: что именно выйдет, выберет планировщик + из указанной группы. Поэтому группа задаётся именем, и имя должно быть точно из списка + доступных ниже. Придумывать новые группы, шоу и стыки нельзя. + - Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой + с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»). + - weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай + «каждый день» там, где содержание не меняется: сетка должна читаться. + - Полосы суток должны покрывать их целиком, без дыр: незакрытое время в эфире займёт + аварийный фон, и это видно зрителю. + - Длина слота — из длительности контента группы, округлённая до четверти часа: программа + передач состоит из круглых времён, а разницу занимает рекламный шов. + - isAnchor=true ставится там, где время объявлено зрителю (начало прайма). Не ставь якорь + на каждый слот подряд. + - kind: "Content" — обычный слот с группой; "Repeat" — повтор прошлого эфира (нужен блок + repeat: daysAgo, time, durationMinutes); "SignOff" — конец вещания. + - blockMode: "Count" — blockValue единиц подряд (серий, фильмов); "FillSlot" — сколько + влезет; "Duration" — blockValue минут. + - strategy.type: "Sequential" — по порядку (для сериалов), "Random" или + "RandomWithCooldown" с cooldownDays — для фильмов и разнобоя. + - Рейтинг соблюдай: строгое (R, Nc17) — только в ночных полосах, детское — утром и днём. + """; + + public const string Schema = """ + Схема ответа (комментарии — пояснения, в JSON их быть не должно): + + { + "format": "telewave.grid", + "version": 1, + "rules": { // необязательный блок правил канала + "maxGenreSharePercent": 40, + "maxBreakMinutesPerHour": 12 + }, + "fallbackGroup": "имя группы", // чем закрывать паузы + "defaultJunction": "имя стыка", // стык между блоками по умолчанию + "layers": [ + { + "name": "Основная сетка", + "priority": 10, + "isEnabled": true, + "applicability": { // необязательно: когда слой действует + "annualRanges": [{ "fromMonth": 12, "fromDay": 25, "toMonth": 1, "toDay": 8 }] + }, + "slots": [ + { + "title": "Утренние мультфильмы", + "start": "06:00", + "durationMinutes": 120, + "daypart": "Morning", // Morning | Day | Prime | Night + "kind": "Content", // Content | Repeat | SignOff + "weekday": null, // 0..6 либо null + "group": "Мультфильмы", // точное имя из списка доступных + "blockMode": "Count", // Count | FillSlot | Duration + "blockValue": 4, + "strategy": { "type": "Sequential", "restartOnEnd": true }, + "isAnchor": false, + "snapToMinutes": 15, + "junctionAfter": "имя стыка" // необязательно + } + ] + } + ] + } + """; + + public const string Answer = """ + Верни только JSON, без пояснений вокруг него и без markdown-ограждения. Этот файл будет + загружен кнопкой импорта как есть. + """; +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommand.cs new file mode 100644 index 0000000..0c97635 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommand.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Transfer; + +/// +/// Загрузка сетки из файла. — снести существующие слоты и построить +/// заново; иначе слои и слоты добавляются к тому, что есть. +/// +public sealed record ImportGridCommand(Guid ChannelId, GridConfig Config, bool Replace) + : ICommand>; + +/// +/// Что получилось. Пропущенные слоты — не ошибка импорта: файл мог прийти от ИИ или с другой +/// установки, где были свои группы, и валить весь импорт из-за одной незнакомой ссылки значит +/// заставить админа чинить JSON руками. +/// +public sealed record GridImportResultDto( + int Layers, + int Slots, + int Skipped, + int Removed, + IReadOnlyList Warnings +); + +public sealed class ImportGridCommandValidator : AbstractValidator +{ + public ImportGridCommandValidator() + { + RuleFor(x => x.Config.Format) + .Equal(GridConfig.CurrentFormat) + .WithMessage($"Ожидается формат «{GridConfig.CurrentFormat}»."); + RuleFor(x => x.Config.Version).InclusiveBetween(1, GridConfig.CurrentVersion); + RuleFor(x => x.Config.Layers).NotEmpty(); + // Потолок на объём файла: недельная сетка — это десятки слотов, а не десятки тысяч. + RuleFor(x => x.Config.Layers.Count).LessThanOrEqualTo(64); + RuleForEach(x => x.Config.Layers) + .ChildRules(layer => + { + layer.RuleFor(l => l.Name).NotEmpty().MaximumLength(128); + layer.RuleFor(l => l.Slots.Count).LessThanOrEqualTo(2048); + }); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs new file mode 100644 index 0000000..08b92b7 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs @@ -0,0 +1,239 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Transfer; + +/// +/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя +/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось. +/// +/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым. +/// +public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter writer) + : 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 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 removed = command.Replace ? Clear(template) : 0; + var created = 0; + var skipped = 0; + + foreach (var configLayer in command.Config.Layers) + { + var layer = EnsureLayer(template, configLayer); + + foreach (var configSlot in configLayer.Slots) + { + var input = ToInput(configSlot, groups, junctions, warnings); + if (input is null) + { + skipped++; + continue; + } + + var applied = await writer.ApplyAsync(layer, null, input, cancellationToken); + if (applied.IsSuccess) + { + created++; + continue; + } + + skipped++; + warnings.Add( + $"Слот «{configSlot.Title}» ({configSlot.Start:HH\\:mm}): {applied.Error.Message}" + ); + } + } + + Apply(template, command.Config, groups, junctions, warnings); + template.MarkChanged(); + + return Result.Success( + new GridImportResultDto( + command.Config.Layers.Count, + created, + skipped, + removed, + warnings + ) + ); + } + + /// Снимает все слоты шаблона и возвращает их число — режим замены. + 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 static void Apply( + ScheduleTemplate template, + GridConfig config, + IReadOnlyDictionary groups, + IReadOnlyDictionary junctions, + List warnings + ) + { + if (config.Rules is { } rules) + template.SetRules(rules.ToJson()); + + if (config.FallbackGroup is { Length: > 0 } fallback) + { + if (Resolve(fallback, groups, "Группа", warnings) is { } groupId) + template.SetFallbackGroup(groupId); + } + + if (config.DefaultJunction is { Length: > 0 } junction) + { + if (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); +} diff --git a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs index 8c63277..c3ce861 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs @@ -155,11 +155,37 @@ public static class JunctionFiller return false; } + if (!NearHour(element, cursor, placement.ChannelOffset)) + return false; + // Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации, // поэтому пересборка хвоста не перетасовывает врезки на каждое применение. return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance); } + /// + /// Рядом ли момент с круглым часом. Сигнал точного времени — «пик-пик-пик» — тем и ценен, что + /// звучит в :00, поэтому врезка с допуском ставится только в его окрестности, а не «когда-нибудь + /// в этом часе». + /// + private static bool NearHour( + PlanningJunctionElement element, + DateTimeOffset cursor, + TimeSpan channelOffset + ) + { + if (element.NearHourMinutes <= 0) + return true; + + var time = cursor.ToOffset(channelOffset).TimeOfDay; + var intoHour = time - TimeSpan.FromHours((int)time.TotalHours); + // Допуск двусторонний: до часа считаем по остатку, после — по прошедшему. + var distance = TimeSpan.FromMinutes( + Math.Min(intoHour.TotalMinutes, 60 - intoHour.TotalMinutes) + ); + return distance <= TimeSpan.FromMinutes(element.NearHourMinutes); + } + /// /// Схлопывает развилки: из врезок с одной меткой остаётся одна, выбранная по весам. Позиция /// развилки в цепочке — позиция её первого участника. diff --git a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs index 752c7f1..0900092 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs @@ -111,6 +111,8 @@ public sealed record PlanningJunctionElement( int Chance = 100, /// Окно времени суток, вне которого врезка не ставится (null — всегда). PlanningTimeWindow? TimeWindow = null, + /// Допуск от круглого часа в минутах; 0 — врезка не привязана к часу. + int NearHourMinutes = 0, /// Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам. string? ChoiceKey = null, int ChoiceWeight = 1, @@ -149,12 +151,32 @@ public sealed record PlanningInput( /// короче одной серии, добор до якоря, пустой слот-повтор. Отдельным списком, потому что /// в основном перекрытых слотов нет — там побеждает слой с большим приоритетом. /// - IReadOnlyList BackgroundSlots = null! + IReadOnlyList BackgroundSlots = null!, + /// + /// Чем добивается шов до круглой отметки и до якоря: ролики и анонсы стыка по умолчанию. + /// На настоящем ТВ остаток до 20:00 занимает реклама, а не ещё одна серия. + /// + IReadOnlyList PadUnits = null!, + /// + /// Потолок одного добора рекламой. Шов в десять минут — это блок роликов, а двухчасовая дыра — + /// уже эфирное время, и заливать её рекламой нельзя. + /// + TimeSpan MaxPadDuration = default ) { public IReadOnlyList BackgroundSlots { get; init; } = BackgroundSlots ?? []; + public IReadOnlyList PadUnits { get; init; } = PadUnits ?? []; + + public TimeSpan MaxPadDuration { get; init; } = + MaxPadDuration > TimeSpan.Zero ? MaxPadDuration : DefaultMaxPad; + + /// Сколько рекламы подряд допустимо в шве, если стык не задал свой потолок. + public static readonly TimeSpan DefaultMaxPad = TimeSpan.FromMinutes(10); } +/// Единица добора шва: ролик и чем он значится в ленте — рекламой или анонсом. +public sealed record PadUnit(PlanningUnit Unit, PlannedItemKind Kind); + /// Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно. public sealed record PlannedItem( Guid MediaAssetId, diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index 5461aab..bac505b 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -42,8 +42,12 @@ public static class SchedulePlanner /// Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент. public Guid? PreviousShowId { get; set; } - /// С какой единицы аварийного запаса продолжать — общий на прогон, см. NextFallbackUnit. - public int FallbackCursor { get; set; } + /// Аварийный запас: кольцевой перебор, общий на прогон. + public UnitRotation Fallback { get; } = + new(input.FallbackUnits, unit => unit.Duration); + + /// Ролики и анонсы для добора шва — тоже по кругу, чтобы не повторяться. + public UnitRotation Pad { get; } = new(input.PadUnits, pad => pad.Unit.Duration); /// Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса. public BackgroundFiller Background { get; } = new(input.BackgroundSlots, random); @@ -117,7 +121,7 @@ public static class SchedulePlanner { // До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно, // для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени. - cursor = FillGap(cursor, slot.TargetStartUtc, run, slot.SlotId); + cursor = FillGap(cursor, slot.TargetStartUtc, run, slot.SlotId, padFirst: true); } else if (slot.SnapToMinutes is { } snap && snap > 0) { @@ -126,7 +130,7 @@ public static class SchedulePlanner var target = RoundUp(cursor, TimeSpan.FromMinutes(snap)); if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes)) { - var afterFill = FillGap(cursor, target, run, slot.SlotId); + var afterFill = FillGap(cursor, target, run, slot.SlotId, padFirst: true); snapped = afterFill > cursor; cursor = afterFill; } @@ -392,11 +396,63 @@ public static class SchedulePlanner /// поломкой и остаётся последней ступенью. /// private static DateTimeOffset FillGap( + DateTimeOffset from, + DateTimeOffset until, + PlanningRun run, + Guid? slotId, + bool padFirst = false + ) + { + // Шов до круглой отметки и до якоря по замыслу занимает реклама: «ещё одна серия вместо + // рекламы» — первое, что выдаёт в эфире плейлист вместо телеканала. + var cursor = padFirst ? FillWithPad(from, until, run, slotId) : from; + + cursor = FillWithBackground(cursor, until, run); + // Остаток, куда программа уже не влезает, — снова шов, и он тоже рекламный. + cursor = FillWithPad(cursor, until, run, slotId); + return FillWithFallback(cursor, until, run, slotId); + } + + /// Ставит ролики и анонсы, пока влезают и пока не исчерпан потолок одного добора. + private static DateTimeOffset FillWithPad( DateTimeOffset from, DateTimeOffset until, PlanningRun run, Guid? slotId ) + { + var limit = Min(until, from + run.Input.MaxPadDuration); + var cursor = from; + var guard = 0; + + while (cursor < limit && guard++ < IterationBackstop) + { + if (run.Pad.Next(limit - cursor) is not { } pad) + break; + + run.Items.Add( + new PlannedItem( + pad.Unit.MediaAssetId, + cursor, + cursor + pad.Unit.Duration, + null, + null, + slotId, + pad.Kind + ) + ); + cursor += pad.Unit.Duration; + } + + return cursor; + } + + /// Закрывает паузу программами фонового слоя — настоящим контентом своего времени суток. + private static DateTimeOffset FillWithBackground( + DateTimeOffset from, + DateTimeOffset until, + PlanningRun run + ) { var cursor = from; var guard = 0; @@ -433,7 +489,7 @@ public static class SchedulePlanner run.PreviousShowId = pick.Unit.ShowId; } - return FillWithFallback(cursor, until, run, slotId); + return cursor; } /// @@ -450,15 +506,12 @@ public static class SchedulePlanner PlanTrace? trace = null ) { - if (run.Input.FallbackUnits.Count == 0 || until <= from) - return from; - var cursor = from; var guard = 0; while (cursor < until && guard++ < IterationBackstop) { - if (NextFallbackUnit(run, until - cursor) is not { } unit) + if (run.Fallback.Next(until - cursor) is not { } unit) break; run.Items.Add( @@ -466,8 +519,8 @@ public static class SchedulePlanner unit.MediaAssetId, cursor, cursor + unit.Duration, - // Фон — это тоже программы из группы, и в эфирной ленте они обязаны быть - // подписаны: без шоу зритель и админ видят на их месте прочерк. + // Аварийная группа — это тоже программы, и в ленте они обязаны быть подписаны: + // без шоу зритель и админ видят на их месте прочерк. Филлер канала шоу не имеет. unit.ShowId == Guid.Empty ? null : unit.ShowId, @@ -483,33 +536,6 @@ public static class SchedulePlanner return cursor; } - /// - /// Следующая единица фона под остаток . Перебор продолжается с того - /// места, где остановился прошлый добор: иначе каждая пауза начиналась бы с одного и того же - /// ролика, и ночь выглядела бы одним зациклённым куском. - /// - /// Если очередная единица не влезает, ищется первая подходящая среди остальных, а не бросается - /// весь добор: список отсортирован по возрастанию длительности, и «не влезла эта» не значит - /// «не влезет ничего» — иначе в ленте оставалась бы дыра при живом коротком контенте. - /// - private static PlanningUnit? NextFallbackUnit(PlanningRun run, TimeSpan available) - { - var units = run.Input.FallbackUnits; - - for (var offset = 0; offset < units.Count; offset++) - { - var index = (run.FallbackCursor + offset) % units.Count; - var unit = units[index]; - if (unit.Duration <= TimeSpan.Zero || unit.Duration > available) - continue; - - run.FallbackCursor = (index + 1) % units.Count; - return unit; - } - - return null; - } - /// Коллекция элемента или null, если в эфир шло отдельное шоу. private static Guid? CollectionOf(PlanningElement element) => element.Kind == GroupElementKind.Collection ? element.ElementId : null; diff --git a/backend/src/TeleWave.Domain/Programming/Planning/UnitRotation.cs b/backend/src/TeleWave.Domain/Programming/Planning/UnitRotation.cs new file mode 100644 index 0000000..a4734e3 --- /dev/null +++ b/backend/src/TeleWave.Domain/Programming/Planning/UnitRotation.cs @@ -0,0 +1,30 @@ +namespace TeleWave.Domain.Programming.Planning; + +/// +/// Кольцевой перебор единиц с проверкой «влезает ли в остаток». Общий и для добора шва рекламой, +/// и для аварийного запаса: в обоих случаях нужно продолжать с места прошлого раза — иначе каждая +/// пауза начиналась бы с одного и того же ролика, — и уметь взять другую единицу, когда очередная +/// не влезла. «Не влезла эта» не значит «не влезет ничего»: в списке есть и короткие. +/// +public sealed class UnitRotation(IReadOnlyList items, Func duration) + where T : class +{ + private int _cursor; + + public T? Next(TimeSpan available) + { + for (var offset = 0; offset < items.Count; offset++) + { + var index = (_cursor + offset) % items.Count; + var item = items[index]; + var length = duration(item); + if (length <= TimeSpan.Zero || length > available) + continue; + + _cursor = (index + 1) % items.Count; + return item; + } + + return null; + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPlaceholdersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPlaceholdersTests.cs index c3a9624..6a31440 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPlaceholdersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPlaceholdersTests.cs @@ -66,19 +66,28 @@ public class BumperPlaceholdersTests { var empty = new BumperContext("Канал", null, Moment); - // Ни следующей программы, ни номера канала: строка схлопывается, осиротевший разделитель уходит. - Assert.Equal("СЕЙЧАС", BumperPlaceholders.Resolve("СЕЙЧАС — {now.title}", empty)); + // Ни текущей, ни следующей программы: строка писалась ради них и исчезает целиком — + // «СЕЙЧАС» в кадре без названия ничего не значит. + Assert.Equal("", BumperPlaceholders.Resolve("СЕЙЧАС — {now.title}", empty)); Assert.Equal("", BumperPlaceholders.Resolve("{next.title}", empty)); + // А подставилось хоть что-то — строка остаётся, осиротевший хвост подчищается. Assert.Equal("Канал", BumperPlaceholders.Resolve("{channel} {channel.number}", empty)); - // А висящий предлог остаётся: отличить его от слова в чужом тексте нельзя. - Assert.Equal("ДАЛЕЕ В", BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time}", empty)); + // Строка, где не подставился ни один плейсхолдер, исчезает целиком: «ДАЛЕЕ В» без времени — + // это мусор в кадре, а не подпись. + Assert.Equal("", BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time}", empty)); + // А постоянный текст без плейсхолдеров остаётся всегда. + Assert.Equal("СЕЙЧАС", BumperPlaceholders.Resolve("СЕЙЧАС", empty)); } [Fact] - public void Resolve_UnknownTokenBecomesEmpty() + public void Resolve_UnknownTokenLeavesNothing() { - Assert.Equal("Далее", BumperPlaceholders.Resolve("Далее {next.tittle}", Full())); + // Незнакомый плейсхолдер подставить нечем, а значит и строка пуста — как если бы данных + // не нашлось. Сохранить такую строку валидатор всё равно не даст. + Assert.Equal("", BumperPlaceholders.Resolve("Далее {next.tittle}", Full())); Assert.Equal("", BumperPlaceholders.Resolve(" ", Full())); + // Постоянный текст без плейсхолдеров остаётся как есть. + Assert.Equal("Далее", BumperPlaceholders.Resolve("Далее", Full())); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperResolverTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperResolverTests.cs index 9aa9f60..a0e0895 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperResolverTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperResolverTests.cs @@ -340,6 +340,74 @@ public class BumperResolverTests ); } + [Fact] + public async Task Resolve_AnnouncesTonightsPrime() + { + var template = TemplateWith(Title("СЕГОДНЯ В {tonight.time} — {tonight.title}")); + + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + // Канал в UTC: так время в анонсе читается без пересчёта в уме. + channel.UpdateTimeSettings(null, 0, Channel.DefaultDayStartTime); + var from = Show.Create("Симпсоны", ShowKind.Series); + var to = Show.Create("Гриффины", ShowKind.Series); + var feature = Show.Create("Терминатор", ShowKind.Single); + + var grid = ScheduleTemplate.Create(channel.Id, "Сетка"); + var day = grid.Background!.AddSlot("Дневной блок", new TimeOnly(14, 0), 240); + var prime = grid.Background!.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120); + prime.UpdateTiming(null, new TimeOnly(20, 0), 120, Daypart.Prime, false, 30, null); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.BumperTemplates.Add(template); + seed.Shows.AddRange(from, to, feature); + seed.ScheduleTemplates.Add(grid); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var items = Lente(template.Id, from.Id, to.Id, day.Id); + // Вечером тех же вещательных суток стоит фильм в прайме — его и должен назвать анонс. + items.Add( + new PlannedItem( + Guid.NewGuid(), + T0.AddHours(2), + T0.AddHours(4), + feature.Id, + 0, + prime.Id, + PlannedItemKind.Program + ) + ); + + await using var db = fixture.New(); + var assets = await NewResolver(db).ResolveAsync(channel, items, CancellationToken.None); + await db.SaveChangesAsync(CancellationToken.None); + + await using var verify = fixture.New(); + var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assets[1]); + var line = Assert.Single(BumperRenderedText.FromJson(cache.RenderedLinesJson)); + Assert.Equal("СЕГОДНЯ В 20:00 — Терминатор", line.Text); + } + + [Fact] + public async Task Resolve_SkipsAnnouncementThatAlreadyStarted() + { + var template = TemplateWith(Title("СЕГОДНЯ В {tonight.time} — {tonight.title}")); + var (fixture, channel, from, to) = await SeedAsync(template); + + // Прайма впереди нет — анонсировать нечего, и строка обязана схлопнуться, а не соврать. + await using var db = fixture.New(); + var assets = await NewResolver(db) + .ResolveAsync(channel, Lente(template.Id, from.Id, to.Id), CancellationToken.None); + await db.SaveChangesAsync(CancellationToken.None); + + await using var verify = fixture.New(); + var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assets[1]); + Assert.Empty(BumperRenderedText.FromJson(cache.RenderedLinesJson)); + } + [Fact] public async Task Resolve_IgnoresReservationsWithoutTemplate() { diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs index 0fd6ada..44e2bb3 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs @@ -107,7 +107,9 @@ public class GenerateGridTests return (channel.Id, template); } - private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) => + private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) => new(db, Catalog(db)); + + private static GroupCatalog Catalog(Common.Interfaces.IAppDbContext db) => new(db, GroupServices.Dynamic(db), new GroupElementResolver(db)); private static async Task PlanAsync( @@ -149,10 +151,15 @@ public class GenerateGridTests Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes); // Полосы без ротации свободны во все семь дней — они идут «каждый день», а не семью копиями. Assert.All( - plan.Slots.Where(s => s.Start < new TimeOnly(20, 0)), + plan.Slots.Where(s => s.Start < new TimeOnly(14, 0)), slot => Assert.Null(slot.Weekday) ); - // Прайм (с 20:00) чередуется по дням недели, поэтому у него дни проставлены явно. + // Дневной блок и прайм чередуются по дням недели: иначе неделя выглядит одним повторяющимся + // днём. У них дни проставлены явно. + Assert.All( + plan.Slots.Where(s => s.Start >= new TimeOnly(14, 0) && s.Start < new TimeOnly(18, 0)), + slot => Assert.NotNull(slot.Weekday) + ); Assert.All( plan.Slots.Where(s => s.Start == new TimeOnly(20, 0)), slot => Assert.NotNull(slot.Weekday) @@ -163,6 +170,35 @@ public class GenerateGridTests ); } + [Fact] + public async Task NeighbouringSlots_DoNotShareTheSameGroup() + { + // Две группы и целые сутки полос: раскладка обязана их чередовать, а не ставить одну + // и ту же встык — два блока подряд из одной группы читаются как один длинный. + var fixture = new TestDb(); + var first = Playable("Первый сериал", ShowKind.Series, 60, 25); + var second = Playable("Второй сериал", ShowKind.Series, 60, 25); + var (channelId, _) = await SeedChannelAsync( + fixture, + [first.Show, second.Show], + [.. first.Assets, .. second.Assets], + [GroupOf("Первые", first.Show.Id), GroupOf("Вторые", second.Show.Id)] + ); + + var plan = await PlanAsync(fixture, channelId); + + var byDay = plan + .Slots.Where(s => s.GroupId is not null) + .GroupBy(s => s.Weekday) + .Select(g => g.OrderBy(s => s.Start).ToList()); + + foreach (var day in byDay) + { + for (var i = 1; i < day.Count; i++) + Assert.NotEqual(day[i - 1].GroupId, day[i].GroupId); + } + } + [Fact] public async Task PrimeGetsFeature_AndMorningGetsSeries() { @@ -322,10 +358,117 @@ public class GenerateGridTests } [Fact] - public async Task SlotLength_FollowsContent_NotProfileConstant() + public async Task Rebuild_BuildsSeasonLayer_WithAnnualApplicability() { - // Серии по 25 минут: двухчасовая полоса должна разложиться на блоки, кратные серии, - // а не отдать десятки минут фону. + var fixture = new TestDb(); + var series = Playable("Сериал", ShowKind.Series, 40, 25); + var films = Films(8, 100); + var (channelId, _) = await SeedChannelAsync( + fixture, + [series.Show, .. films.Shows], + [.. series.Assets, .. films.Assets], + [ + GroupOf("Сериалы", series.Show.Id), + GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]), + ] + ); + + await using (var db = fixture.New()) + { + var result = await new GenerateGridCommandHandler( + db, + Planner(db), + new SlotWriter(db) + ).Handle( + new GenerateGridCommand( + channelId, + GridProfileKind.Mixed, + GridGenerationMode.Rebuild + ), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var check = fixture.New(); + var stored = await check + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstAsync(CancellationToken.None); + + var season = GridProfiles.Get(GridProfileKind.Mixed).Season; + Assert.NotNull(season); + var layer = Assert.Single(stored.Layers, l => l.Name == season.Name); + + // Праздничный слой обязан перекрывать и будни, и выходные — иначе 1 января пройдёт как + // обычное воскресенье. + var otherPriority = stored.Layers.Where(l => l.Id != layer.Id).Max(l => l.Priority); + Assert.True(layer.Priority > otherPriority); + Assert.NotEmpty(layer.Slots); + + // Слой действует по ежегодному периоду: заводить его заново каждый декабрь не нужно. + var applicability = LayerApplicability.FromJson(layer.ApplicabilityJson); + Assert.NotNull(applicability); + Assert.Equal([season.Range], applicability.AnnualRanges); + Assert.True(applicability.Covers(new DateOnly(2027, 1, 1))); + Assert.False(applicability.Covers(new DateOnly(2027, 3, 1))); + } + + [Fact] + public async Task Season_DoesNotSpendTheWeeksBudget() + { + // Праздничная неделя идёт вместо обычной, а не вдобавок к ней: её слоты не должны попадать + // в подсчёт закрытого времени, иначе генератор решит, что неделя перекрыта с запасом. + var fixture = new TestDb(); + var series = Playable("Сериал", ShowKind.Series, 40, 25); + var films = Films(8, 100); + var (channelId, _) = await SeedChannelAsync( + fixture, + [series.Show, .. films.Shows], + [.. series.Assets, .. films.Assets], + [ + GroupOf("Сериалы", series.Show.Id), + GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]), + ] + ); + + var plan = await PlanAsync( + fixture, + channelId, + GridProfileKind.Mixed, + GridGenerationMode.Rebuild + ); + + Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Season); + Assert.All( + plan.Slots.Where(s => s.Layer == GridPlanLayer.Season), + s => Assert.Null(s.Weekday) + ); + Assert.True(plan.CoveredMinutes <= plan.FreeMinutes); + } + + [Fact] + public void Sitcoms_AirInPairs() + { + // Ситком — это пара серий и рекламный шов между парами. Три-четыре подряд читаются как + // марафон, а марафон у ситкомового канала бывает по выходным, а не каждый будний день. + var sitcom = GridProfiles.Get(GridProfileKind.Sitcom); + var blocks = sitcom + .Weekdays.Where(b => b.PreferKind == ShowKind.Series && b.UnitsPerBlock > 0) + .ToList(); + + Assert.NotEmpty(blocks); + Assert.All(blocks, b => Assert.Equal(2, b.UnitsPerBlock)); + } + + [Fact] + public async Task SlotLength_FollowsContent_RoundedToTheGrid() + { + // Серии по 25 минут: двухчасовая полоса должна разложиться на блоки под серии, а не + // отдать десятки минут фону. Длина при этом округляется вверх до четверти часа — + // программа передач состоит из круглых времён, а разницу занимает рекламный шов. var fixture = new TestDb(); var series = Playable("Сериал", ShowKind.Series, 60, 25); var (channelId, _) = await SeedChannelAsync( @@ -342,8 +485,12 @@ public class GenerateGridTests morning, slot => { - // Слот вмещает ровно столько серий, сколько в нём заявлено, и с точностью до пяти минут. - Assert.Equal(slot.DurationMinutes, slot.BlockValue * 25); + var content = slot.BlockValue * 25; + // Слот не короче своего контента — иначе блок перелезет через объявленное время. + Assert.True(slot.DurationMinutes >= content); + // И не длиннее его больше, чем на шаг решётки: остаток — это шов, а не пустота. + Assert.True(slot.DurationMinutes - content < 15); + Assert.Equal(0, slot.DurationMinutes % 15); Assert.Equal(SlotBlockMode.Count, slot.BlockMode); } ); diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs index c459e52..6aaed92 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs @@ -116,7 +116,10 @@ public class GridPlanPreviewTests await using var db = fixture.New(); var result = await new PreviewGeneratedGridQueryHandler( - new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) + new GridPlanner( + db, + new GroupCatalog(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) + ) ).Handle( new PreviewGeneratedGridQuery( channelId, @@ -143,7 +146,10 @@ public class GridPlanPreviewTests await using var db = fixture.New(); var result = await new PreviewGeneratedGridQueryHandler( - new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) + new GridPlanner( + db, + new GroupCatalog(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) + ) ).Handle( new PreviewGeneratedGridQuery( Guid.NewGuid(), diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs new file mode 100644 index 0000000..7a3fabe --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs @@ -0,0 +1,378 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.Generate; +using TeleWave.Application.Programming.Templates.Transfer; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Обмен конфигурацией сетки: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ. +/// Главное свойство — ссылки в файле именные: он должен переноситься между каналами и установками, +/// где тех же идентификаторов нет. +/// +public class GridTransferTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static (Show Show, MediaAsset Asset) Playable(string name, int minutes) + { + var show = Show.Create(name, ShowKind.Series); + var asset = MediaAsset.Register($"{name}.mkv", ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + TimeSpan.FromMinutes(minutes), + 6, + minutes * 10, + 1920, + 1080, + "h264", + "aac", + $"assets/{name}" + ) + ); + show.AddEpisode(asset.Id); + return (show, asset); + } + + /// Канал с одной группой, одним стыком и слоем «Основная сетка» из одного слота. + private static async Task SeedAsync(TestDb fixture, bool withSlot = true) + { + var (show, asset) = Playable("Сериал", 25); + var group = Group.Create("Сериалы"); + group.AddElement(GroupElementKind.Show, show.Id); + + var junction = JunctionTemplate.Create("Рекламный шов"); + + var channel = Channel.Create("Первый", "first", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + channel.SetTemplate(template.Id); + template.SetFallbackGroup(group.Id); + template.SetDefaultJunction(junction.Id); + + var layer = template.AddLayer("Основная сетка", 10); + if (withSlot) + { + var slot = layer.AddSlot("Вечерний блок", new TimeOnly(20, 0), 120, Daypart.Prime); + slot.UpdateTiming(null, new TimeOnly(20, 0), 120, Daypart.Prime, true, 5, 15); + slot.UpdateContent( + new SlotContent( + "Вечерний блок", + SlotKind.Content, + group.Id, + new SlotStrategy(SlotStrategyType.Sequential).ToJson(), + null, + SlotBlockMode.Count, + 4, + OverflowPolicy.ContinueNext, + null, + junction.Id + ) + ); + } + + await using var seed = fixture.New(); + seed.MediaAssets.Add(asset); + seed.Shows.Add(show); + seed.Groups.Add(group); + seed.JunctionTemplates.Add(junction); + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + + return channel.Id; + } + + private static async Task ExportAsync(TestDb fixture, Guid channelId) + { + await using var db = fixture.New(); + var result = await new ExportGridQueryHandler(db).Handle( + new ExportGridQuery(channelId), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + return result.Value; + } + + private static async Task ImportAsync( + TestDb fixture, + Guid channelId, + GridConfig config, + bool replace = true + ) + { + await using var db = fixture.New(); + var result = await new ImportGridCommandHandler(db, new SlotWriter(db)).Handle( + new ImportGridCommand(channelId, config, replace), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + return result.Value; + } + + [Fact] + public async Task Export_ReferencesGroupsAndJunctionsByName() + { + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture); + + var config = await ExportAsync(fixture, channelId); + + Assert.Equal(GridConfig.CurrentFormat, config.Format); + Assert.Equal(GridConfig.CurrentVersion, config.Version); + Assert.Equal("Сериалы", config.FallbackGroup); + Assert.Equal("Рекламный шов", config.DefaultJunction); + Assert.Equal("Первый", config.Channel?.Name); + + var layer = Assert.Single(config.Layers, l => !l.IsBackground); + var slot = Assert.Single(layer.Slots); + Assert.Equal("Сериалы", slot.Group); + Assert.Equal("Рекламный шов", slot.JunctionAfter); + Assert.True(slot.IsAnchor); + Assert.Equal(15, slot.SnapToMinutes); + + // Файл должен быть читаемым JSON — его правят руками и отдают модели. + Assert.Contains("\"format\": \"telewave.grid\"", config.ToJson(), StringComparison.Ordinal); + } + + [Fact] + public async Task Import_RestoresTheSameGrid_AfterExport() + { + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture); + var config = await ExportAsync(fixture, channelId); + + // Через сериализацию — так файл и приходит обратно, а не объектом в памяти. + var roundTripped = JsonSerializer.Deserialize( + config.ToJson(), + GridConfig.Options + ); + Assert.NotNull(roundTripped); + + var imported = await ImportAsync(fixture, channelId, roundTripped); + + Assert.Equal(1, imported.Slots); + Assert.Equal(0, imported.Skipped); + Assert.Equal(1, imported.Removed); + Assert.Empty(imported.Warnings); + + var restored = await ExportAsync(fixture, channelId); + Assert.Equal(config.Layers.Count, restored.Layers.Count); + Assert.Equal( + config.Layers.SelectMany(l => l.Slots), + restored.Layers.SelectMany(l => l.Slots) + ); + } + + [Fact] + public async Task Import_ReusesLayerByName_AndDoesNotDuplicateIt() + { + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture); + var config = await ExportAsync(fixture, channelId); + + await ImportAsync(fixture, channelId, config); + await ImportAsync(fixture, channelId, config); + + await using var db = fixture.New(); + var template = await db + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstAsync(CancellationToken.None); + + Assert.Single(template.Layers, l => l.Name == "Основная сетка"); + Assert.Equal(1, template.Layers.Sum(l => l.Slots.Count)); + } + + [Fact] + public async Task Import_SkipsUnknownGroup_AndSaysSo() + { + 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: "Сериалы с другого канала" + ), + new GridConfigSlot( + "Свой блок", + new TimeOnly(22, 0), + 60, + Daypart.Night, + Group: "Сериалы" + ), + ] + ), + ]); + + var imported = await ImportAsync(fixture, channelId, config); + + // Одна незнакомая ссылка не должна ронять весь файл — иначе админ чинит JSON руками. + Assert.Equal(1, imported.Slots); + Assert.Equal(1, imported.Skipped); + Assert.Contains( + imported.Warnings, + w => w.Contains("Сериалы с другого канала", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task Import_SkipsOverlappingSlot_AndKeepsTheGridValid() + { + 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: "Сериалы" + ), + new GridConfigSlot( + "Наложился", + new TimeOnly(21, 0), + 60, + Daypart.Prime, + Group: "Сериалы" + ), + ] + ), + ]); + + var imported = await ImportAsync(fixture, channelId, config); + + Assert.Equal(1, imported.Slots); + Assert.Equal(1, imported.Skipped); + Assert.NotEmpty(imported.Warnings); + } + + [Fact] + public async Task Import_MarksTemplateChanged_ButDoesNotTouchAir() + { + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture); + var config = await ExportAsync(fixture, channelId); + + await using (var applied = fixture.New()) + { + var template = await applied.ScheduleTemplates.FirstAsync(CancellationToken.None); + template.MarkApplied(); + await applied.SaveChangesAsync(CancellationToken.None); + } + + await ImportAsync(fixture, channelId, config); + + await using var db = fixture.New(); + var stored = await db.ScheduleTemplates.FirstAsync(CancellationToken.None); + Assert.True(stored.HasPendingChanges); + } + + [Fact] + public async Task Prompt_ListsGroupsAndSchema() + { + 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, + ["2×2", "Paramount Comedy"], + "Хочу ситкомы вечером" + ), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var prompt = result.Value.Prompt; + + // Модель обязана видеть, чем канал располагает и в каком виде ждут ответ. + Assert.Contains("Сериалы", prompt, StringComparison.Ordinal); + Assert.Contains("2×2", prompt, StringComparison.Ordinal); + Assert.Contains("Хочу ситкомы вечером", prompt, StringComparison.Ordinal); + Assert.Contains("Рекламный шов", prompt, StringComparison.Ordinal); + Assert.Contains(GridConfig.CurrentFormat, prompt, StringComparison.Ordinal); + Assert.Equal(1, result.Value.Groups); + Assert.Equal(1, result.Value.Shows); + Assert.Equal(prompt.Length, result.Value.Characters); + } + + [Fact] + public async Task Prompt_SchemaSurvivesImport() + { + // Схема в запросе и импорт обязаны быть одним форматом: пример из запроса должен читаться + // импортом без правок, иначе ответ модели придётся чинить руками. + var fixture = new TestDb(); + var channelId = await SeedAsync(fixture, withSlot: false); + + var answer = """ + { + "format": "telewave.grid", + "version": 1, + "fallbackGroup": "Сериалы", + "layers": [ + { + "name": "Основная сетка", + "priority": 10, + "slots": [ + { + "title": "Вечерний блок", + "start": "20:00", + "durationMinutes": 120, + "daypart": "Prime", + "kind": "Content", + "group": "Сериалы", + "blockMode": "Count", + "blockValue": 4, + "strategy": { "type": "Sequential", "restartOnEnd": true }, + "isAnchor": true, + "snapToMinutes": 15 + } + ] + } + ] + } + """; + + var config = JsonSerializer.Deserialize(answer, GridConfig.Options); + Assert.NotNull(config); + + var imported = await ImportAsync(fixture, channelId, config); + + Assert.Equal(1, imported.Slots); + Assert.Empty(imported.Warnings); + + var stored = await ExportAsync(fixture, channelId); + var slot = Assert.Single(stored.Layers.SelectMany(l => l.Slots)); + Assert.Equal("Вечерний блок", slot.Title); + Assert.Equal(SlotBlockMode.Count, slot.BlockMode); + Assert.True(slot.IsAnchor); + } + + private static GroupCatalog Catalog(IAppDbContext db) => + new(db, GroupServices.Dynamic(db), new GroupElementResolver(db)); +} diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs index f3237f9..ac010e4 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs @@ -42,7 +42,8 @@ public class JunctionFillerTests PlanningTimeWindow? window = null, string? choiceKey = null, int choiceWeight = 1, - int poolSize = 8 + int poolSize = 8, + int nearHourMinutes = 0 ) => new( Guid.NewGuid(), @@ -55,6 +56,7 @@ public class JunctionFillerTests minMinutesBetween, chance, window, + nearHourMinutes, choiceKey, choiceWeight ); @@ -234,6 +236,58 @@ public class JunctionFillerTests Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); } + [Fact] + public void NearHour_SkipsAwayFromRoundHour() + { + // Серии по 20 минут: стыки приходятся на 18:20 и 18:40 — сигналу точного времени там не место. + var element = Series(3, 20, out _); + var result = Run( + Slot( + element, + 3, + between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)]) + ) + ); + + Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); + } + + [Fact] + public void NearHour_PlacesOnTheHour() + { + // Часовые серии: стыки ровно в 19:00 и 20:00 — врезка с допуском срабатывает на каждом. + var element = Series(3, 60, out _); + var result = Run( + Slot( + element, + 3, + between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)]) + ) + ); + + var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList(); + Assert.Equal(2, ads.Count); + Assert.All(ads, ad => Assert.Equal(0, ad.StartsAtUtc.Minute)); + } + + [Fact] + public void NearHour_CountsChannelTime_NotUtc() + { + // Канал в UTC+3:30: круглый час канала приходится на :30 по UTC, и считать надо по каналу — + // «московское время» объявляют в эфире, а не в UTC. + var element = Series(3, 60, out _); + var result = Run( + Slot( + element, + 3, + between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)]) + ), + utcOffsetMinutes: 210 + ); + + Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); + } + [Fact] public void Choice_PlacesExactlyOneOfTheFork() { diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs index 98e7156..b862122 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/SchedulePlannerTests.cs @@ -75,7 +75,9 @@ public class SchedulePlannerTests DateTimeOffset? start = null, int horizonHours = 6, IReadOnlyList? fallback = null, - IReadOnlyList? background = null + IReadOnlyList? background = null, + IReadOnlyList? pad = null, + TimeSpan maxPad = default ) => new( Guid.NewGuid(), @@ -85,9 +87,13 @@ public class SchedulePlannerTests fallback ?? [Unit(1, Guid.NewGuid(), 0)], SegmentSeconds: 2, UtcOffsetMinutes: 0, - background ?? [] + background ?? [], + pad ?? [], + maxPad ); + private static PadUnit Ad(int minutes) => new(Unit(minutes, Guid.Empty, 0), PlannedItemKind.Ad); + private static PlanningResult Run(PlanningInput input) => TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways()); @@ -350,6 +356,72 @@ public class SchedulePlannerTests Assert.Equal(element.ElementId, program.Trace.ElementId); } + [Fact] + public void Pad_FillsSeamBeforeSlotWithAds() + { + // Слот-якорь начинается через полчаса после конца предыдущего: шов до объявленного времени + // на настоящем ТВ занимает реклама, а не ещё одна серия. + var anchor = Slot(T0.AddMinutes(30), 30, [Element(1, 30)], isAnchor: true); + + var result = Run( + Input( + [Slot(T0, 15, [Element(1, 15)]), anchor], + horizonHours: 1, + pad: [Ad(5)], + maxPad: TimeSpan.FromMinutes(30) + ) + ); + + var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList(); + Assert.Equal(3, ads.Count); + Assert.Equal(T0.AddMinutes(15), ads[0].StartsAtUtc); + // К объявленному времени шов заканчивается: якорь начинается ровно в свои полчаса. + Assert.Equal(anchor.TargetStartUtc, ads[^1].EndsAtUtc); + } + + [Fact] + public void Pad_IsCappedSoLongHolesStayProgramming() + { + // Дыра в час: рекламой закрывается только потолок одного добора, остальное — программы. + var background = Slot(T0, 24 * 60, [Element(6, 10)]); + + var result = Run( + Input( + [Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)], + horizonHours: 2, + pad: [Ad(5)], + maxPad: TimeSpan.FromMinutes(10), + background: [background] + ) + ); + + var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList(); + Assert.Equal( + TimeSpan.FromMinutes(10), + ads.Aggregate(TimeSpan.Zero, (sum, a) => sum + (a.EndsAtUtc - a.StartsAtUtc)) + ); + Assert.Contains(result.Items, i => i.SlotId == background.SlotId); + } + + [Fact] + public void Pad_ClosesTailWhenProgrammeDoesNotFit() + { + // Остаток в пять минут: серия фона туда не влезает, и хвост занимает реклама, а не филлер. + var background = Slot(T0, 24 * 60, [Element(4, 30)]); + + var result = Run( + Input( + [Slot(T0, 55, [Element(1, 55)])], + horizonHours: 1, + pad: [Ad(5)], + background: [background] + ) + ); + + Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad); + Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback); + } + [Fact] public void Background_PlaysInLeftoverInsteadOfEmergencyFiller() { diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index abe7afe..8074572 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -454,7 +454,8 @@ JunctionElement "minMinutesBetween": 30, "dayparts": ["prime", "day"], "timeWindow": { "from": "20:00", "to": "23:00" }, - "chance": 40 + "chance": 40, + "nearHourMinutes": 2 } ``` @@ -465,6 +466,10 @@ JunctionElement стыков ставим анонс». `minMinutesBetween` считается **по конкретной врезке**, а не по её виду: две рекламные врезки в разных стыках — это разные ограничения, общий счётчик на вид склеил бы их. +`nearHourMinutes` — допуск от круглого часа по времени канала. Сигнал точного времени и джингл тем +и узнаются, что звучат в :00; врезка с допуском ставится только в его окрестности, а не «когда-нибудь +в этом часе». Ноль — привязки нет. + **Развилка** (`choiceKey`) — несколько врезок с одной меткой, из которых играет одна, выбранная по весам: «иногда заставка, иногда короткий рекламный блок». Врезки одной развилки обязаны занимать непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия @@ -565,11 +570,20 @@ BumperLine | `{now.episode}` `{next.episode}` | «с5э12» либо название серии | | `{next.year}` `{next.genre}` | из метаданных | | `{next.time}` | во сколько начнётся следующая программа | +| `{tonight.title}` `{tonight.time}` | первая программа прайма этих вещательных суток | +| `{tomorrow.title}` `{tomorrow.time}` | то же для следующих суток | | `{time}` `{date}` `{weekday}` | момент показа заставки во времени канала | | `{slot}` | название слота — «Вечернее кино» | -Неизвестное значение подставляется пустым, строка со схлопнувшимися пробелами не рисуется. -Неизвестный плейсхолдер — **ошибка валидации при сохранении**, а не сюрприз в эфире. +Анонсы смотрят **вперёд по собранной ленте** и только на программы, которые ещё не начались: +«сегодня в 20:00 — Терминатор», сказанное в девять вечера, хуже, чем молчание. Ночь до +`dayStartTime` относится к предыдущим вещательным суткам, иначе «сегодня вечером» в час ночи +означало бы уже следующий вечер. + +Неизвестное значение подставляется пустым. Строка, в которой **не подставился ни один** +плейсхолдер, не рисуется целиком: она писалась ради данных, и «ДАЛЕЕ В» без времени — это не +подпись, а мусор в кадре. Постоянного текста без плейсхолдеров это не касается. Неизвестный +плейсхолдер — **ошибка валидации при сохранении**, а не сюрприз в эфире. Цена гибкости — кэш: `{time}` и `{date}` делают каждый показ уникальным, а рендер это ffmpeg на несколько секунд, помноженный на недельный горизонт. Запрещать нечего, но редактор обязан @@ -1034,6 +1048,15 @@ seed = hash(channelId, date, slotId, occurrenceInDay) не лучшего кандидата, а следующего по кругу: иначе неделя выглядит одним повторяющимся днём. Остальные полосы остаются слотами «каждый день» — сетка должна читаться. +**Праздничная сетка — сезонный слой.** В режиме «с нуля» генератор строит ещё один слой поверх +основного и выходных, с ежегодной применимостью (25 декабря — 8 января): днём марафон, вечером кино. +Он один и тот же у всех профилей намеренно — в праздники каналы сходятся к одному ритму, и различает +их библиотека, а не сетка. Слоты слоя идут «каждый день»: период занимает несколько дат подряд, и +расписывать их по дням недели значило бы получить сетку, зависящую от того, на какой день выпало +31 декабря. Свой разбор и свой бюджет: праздничная неделя идёт **вместо** обычной, а не вдобавок, +и обеднять ради неё обычную нельзя. Даты и содержимое слоя дальше правятся как у любого другого — +повторная генерация применимость уже созданного слоя не переписывает. + **Жанр не занимает две полосы подряд**, а при заданном `MaxGenreSharePercent` — и больше своей доли суток. Это тот же потолок, который потом проверяет пост-проверка из 3.8, но соблюдаемый на раскладке, а не констатируемый после. @@ -1045,6 +1068,32 @@ seed = hash(channelId, date, slotId, occurrenceInDay) с тем, что потом скажут проверки сетки. Эфир генерация не двигает — как любая правка сетки, она только поднимает ревизию шаблона, а хвост пересобирает кнопка применения. +### 6.10. Обмен конфигурацией сетки + +Сетка переносится файлом: выгрузка, загрузка и запрос к ИИ — три вкладки одного диалога. + +**Ссылки в файле именные.** Группа, стык и аварийная группа записываются названиями, а не +идентификаторами: файл едет на другой канал и на другую установку, где тех же GUID нет, а сетку для +импорта пишет в том числе языковая модель — идентификаторы она может только выдумать. Импорт +разрешает имена сам; незнакомое имя не валит загрузку, а становится замечанием, и админ видит +списком, чего в библиотеке не нашлось. Тем же способом пропускаются слоты, которые не проходят +проверки (перекрытие, нет группы у содержательного слота). + +Слой берётся по имени, поэтому повторная загрузка того же файла даёт ту же сетку, а не +«Основная сетка (2)». Режим «снести существующие слоты» работает как пересборка из 6.9. Эфир +импорт не двигает: он помечает шаблон изменённым, а хвост пересобирает кнопка применения. + +**Запрос к ИИ собирается на сервере и никуда не отправляется.** Ключей внешних сервисов проект +не хранит, и заводить их ради одной кнопки не нужно: админ вводит референс-каналы («как у 2×2») +и пожелания своими словами, получает готовый текст, копирует его в ту модель, которой пользуется, +и приносит ответ назад вкладкой импорта. + +В запрос входят параметры канала, доступные группы с их ёмкостью и средней длиной единицы (те же +числа, по которым раскладывает автосборка, — считает их общий `GroupCatalog`), библиотека справкой, +имена стыков и заставок, правила раскладки и схема ответа. Схема лежит рядом с форматом +(`GridPromptText` в одной папке с `GridConfig`), а не в шаблоне на фронте: разойдись она с импортом +— ответ модели пришлось бы чинить руками. + --- ## 7. Порядок реализации diff --git a/frontend/src/features/admin/bumpers/placeholders.ts b/frontend/src/features/admin/bumpers/placeholders.ts index 8f31709..c5eb36f 100644 --- a/frontend/src/features/admin/bumpers/placeholders.ts +++ b/frontend/src/features/admin/bumpers/placeholders.ts @@ -20,6 +20,10 @@ export const PLACEHOLDERS: PlaceholderSample[] = [ { token: 'next.year', sample: '1991' }, { token: 'next.genre', sample: 'Боевик' }, { token: 'next.time', sample: '21:30' }, + { token: 'tonight.title', sample: 'Терминатор 2' }, + { token: 'tonight.time', sample: '20:00' }, + { token: 'tomorrow.title', sample: 'Чужие' }, + { token: 'tomorrow.time', sample: '20:00' }, { token: 'time', sample: '21:24' }, { token: 'date', sample: '6 апреля' }, { token: 'weekday', sample: 'понедельник' }, @@ -33,12 +37,29 @@ const TOKEN_PATTERN = /\{([a-zA-Z][a-zA-Z.]*)\}/g const SAMPLES = new Map(PLACEHOLDERS.map((p) => [p.token, p.sample])) -/** Как строка будет выглядеть в кадре: подстановка образцами + схлопывание лишних пробелов. */ +/** + * Как строка будет выглядеть в кадре. Зеркало серверного `BumperPlaceholders.Resolve`: пустые + * значения схлопываются вместе с осиротевшими разделителями, а строка, где не подставился ни один + * плейсхолдер, исчезает целиком — «ДАЛЕЕ В» без времени это не подпись, а мусор в кадре. + */ export function resolveSample(text: string) { - return text - .replace(TOKEN_PATTERN, (_, token: string) => SAMPLES.get(token) ?? '') + let tokens = 0 + let filled = 0 + + const resolved = text.replace(TOKEN_PATTERN, (_, token: string) => { + tokens += 1 + const value = SAMPLES.get(token) ?? '' + if (value) filled += 1 + return value + }) + + if (tokens > 0 && filled === 0) return '' + + return resolved .replace(/[ \t]{2,}/g, ' ') .trim() + .replace(/^[—–\-:·,;/]+|[—–\-:·,;/]+$/g, '') + .trim() } /** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */ diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index c10998c..52b0238 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -164,6 +164,7 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) { {tab === 'grid' && ( (`/admin/channels/${channelId}/template/export`) +} + +/** + * Загрузка сетки из файла. `replace` — снести существующие слоты и построить заново; иначе слои + * и слоты добавляются к тому, что уже есть. + */ +export function importGrid(channelId: string, config: GridConfig, replace: boolean) { + return apiRequest(`/admin/channels/${channelId}/template/import`, { + method: 'POST', + body: { config, replace }, + }) +} + +/** + * Собирает текст запроса к ИИ по референс-каналам и пожеланиям. Никуда не отправляется — админ + * копирует его в свою модель и приносит ответ назад кнопкой импорта. + */ +export function buildGridPrompt(channelId: string, references: string[], notes: string) { + return apiRequest(`/admin/channels/${channelId}/template/ai-prompt`, { + method: 'POST', + body: { references, notes: notes || null }, + }) +} + /** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */ export function copyTemplateTo(channelId: string, targetChannelId: string) { return apiRequest( diff --git a/frontend/src/features/admin/channels/components/GridTab.tsx b/frontend/src/features/admin/channels/components/GridTab.tsx index c897b2a..eb6425f 100644 --- a/frontend/src/features/admin/channels/components/GridTab.tsx +++ b/frontend/src/features/admin/channels/components/GridTab.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query' -import { Plus, Wand2 } from 'lucide-react' +import { FileJson, Plus, Wand2 } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { HttpError } from '@/shared/api/client' @@ -22,6 +22,7 @@ import { } from '../api' import { toTime } from '../lib/format' import { GenerateGridDialog } from './GenerateGridDialog' +import { GridTransferDialog } from './GridTransferDialog' import { LayerApplicabilityDialog } from './LayerApplicabilityDialog' import { LayerList, ScheduleGrid } from './ScheduleGrid' import { SlotInspector, type SlotDraft } from './SlotInspector' @@ -35,12 +36,14 @@ import { TemplatePreview } from './TemplatePreview' */ export function GridTab({ channelId, + channelName, template, templateError, onChanged, onError, }: Readonly<{ channelId: string + channelName: string template: ScheduleTemplateDto | undefined templateError: unknown onChanged: () => void @@ -52,6 +55,7 @@ export function GridTab({ const [viewDate, setViewDate] = useState('') const [applicabilityLayer, setApplicabilityLayer] = useState(null) const [generating, setGenerating] = useState(false) + const [transferring, setTransferring] = useState(false) // День, который копируем, и отмеченные дни-приёмники. const [copySource, setCopySource] = useState(null) const [copyTargets, setCopyTargets] = useState([]) @@ -308,6 +312,10 @@ export function GridTab({ {t('admin.channels.generate.action')} + {t('admin.channels.showForDate')} + {transferring && ( + setTransferring(false)} + onImported={onChanged} + onError={onError} + /> + )} + {generating && ( void + onImported: () => void + onError: (error: unknown) => void +}>) { + const { t } = useTranslation() + const [tab, setTab] = useState('export') + + const [text, setText] = useState('') + const [replace, setReplace] = useState(true) + const [warnings, setWarnings] = useState([]) + const fileInput = useRef(null) + + const [references, setReferences] = useState('') + const [notes, setNotes] = useState('') + const [prompt, setPrompt] = useState('') + + const exportMutation = useMutation({ + mutationFn: () => exportGrid(channelId), + onSuccess: (config) => { + download(channelName, config) + toast.success(t('admin.channels.transfer.exported')) + }, + onError, + }) + + const importMutation = useMutation({ + mutationFn: () => { + // Разбор здесь, а не в мутации-обёртке: битый JSON — самая частая ошибка, и сказать о ней + // надо строкой в диалоге, а не общим «не удалось сохранить». + const config = JSON.parse(text) as GridConfig + return importGrid(channelId, config, replace) + }, + onSuccess: (result) => { + setWarnings(result.warnings) + toast.success( + t('admin.channels.transfer.imported', { + slots: result.slots, + skipped: result.skipped, + }), + ) + onImported() + }, + onError: (error: unknown) => { + if (error instanceof SyntaxError) { + toast.error(t('admin.channels.transfer.badJson')) + return + } + onError(error) + }, + }) + + const promptMutation = useMutation({ + mutationFn: () => + buildGridPrompt( + channelId, + references + .split(/[\n,]/) + .map((r) => r.trim()) + .filter(Boolean), + notes, + ), + onSuccess: (result) => setPrompt(result.prompt), + onError, + }) + + const copyPrompt = async () => { + await navigator.clipboard.writeText(prompt) + toast.success(t('admin.channels.transfer.copied')) + } + + const pickFile = (file: File | undefined) => { + if (!file) return + file + .text() + .then((content) => { + setText(content) + setWarnings([]) + }) + .catch(onError) + } + + return ( + + + + {t('admin.channels.transfer.title')} + {t('admin.channels.transfer.hint')} + + +
+ {TABS.map((item) => ( + + ))} +
+ + {tab === 'export' && ( +
+

{t('admin.channels.transfer.exportHint')}

+
+ +
+
+ )} + + {tab === 'import' && ( +
+

{t('admin.channels.transfer.importHint')}

+ +
+ pickFile(e.target.files?.[0])} + /> + + +
+ +