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])} + /> + + +
+ +