diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 06bdd99..0b80b6b 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -55,6 +55,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/Groups/InterstitialGroups.cs b/backend/src/TeleWave.Application/Programming/Groups/InterstitialGroups.cs new file mode 100644 index 0000000..c46b847 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/InterstitialGroups.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups; + +/// +/// Отличает группу роликов от группы контента. +/// +/// Ролик — то же шоу с одной «серией» (см. 6.7), поэтому «Реклама» технически неотличима от +/// «Сериалов»: те же позиции, тот же разворот, то же остывание. В эфире разница огромная — слот +/// по роликам выдаёт полосу рекламы, подписанную как программа, а аварийная группа из роликов +/// превращает в рекламу каждую паузу. Оба места спрашивают одно и то же, поэтому признак живёт +/// здесь, а не в проверке каждого из них. +/// +/// Признак — по позициям, а не по единицам: группа роликов состоит из роликов, и сотня коротких +/// единиц против одного длинного сериала здесь ничего не решает. +/// +public sealed class InterstitialGroups(IAppDbContext dbContext) +{ + /// + /// Собрана ли группа из роликов. Группа, которой нет, роликовой не считается: её отсутствие — + /// отдельная ошибка, и подменять её этой значило бы врать в сообщении. + /// + public async Task IsInterstitialAsync(Guid groupId, CancellationToken cancellationToken) + { + // Заведённая тут же группа ещё не в базе: импорт сетки приносит группы с собой и ставит + // слоты в том же прогоне — так же, как это делает проверка «группа существует». + var group = + dbContext.Groups.Local.FirstOrDefault(g => g.Id == groupId) + ?? await dbContext + .Groups.AsNoTracking() + .Include(g => g.Items) + .FirstOrDefaultAsync(g => g.Id == groupId, cancellationToken); + if (group is null) + return false; + + // Правило без указанного типа означает «кино и сериалы»: ролики в динамический состав + // сами не попадают (см. GroupFilterMatcher), и разбирать позиции незачем. + if (group.Mode == GroupMode.Dynamic) + return GroupFilter.FromJson(group.FilterJson) is { ShowKinds: { Count: > 0 } kinds } + && kinds.All(kind => kind == ShowKind.Interstitial); + + var items = group.Items.Where(i => i.Role != GroupItemRole.Excluded).ToList(); + if (items.Count == 0) + return false; + + var showIds = items + .Where(i => i.ElementKind == GroupElementKind.Show) + .Select(i => i.ElementId) + .ToList(); + var collectionIds = items + .Where(i => i.ElementKind == GroupElementKind.Collection) + .Select(i => i.ElementId) + .ToList(); + + // Коллекции разворачиваются, а не пропускаются: рекламный блок собирают и коллекцией — + // так у него получается фиксированный порядок роликов. + var parts = + collectionIds.Count == 0 + ? [] + : await dbContext + .CollectionItems.AsNoTracking() + .Where(i => collectionIds.Contains(i.CollectionId)) + .Select(i => new { i.CollectionId, i.ShowId }) + .ToListAsync(cancellationToken); + + var knownIds = showIds.Concat(parts.Select(p => p.ShowId)).Distinct().ToList(); + var interstitialIds = await dbContext + .Shows.AsNoTracking() + .Where(s => knownIds.Contains(s.Id) && s.Kind == ShowKind.Interstitial) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + var rollers = interstitialIds.ToHashSet(); + + var byCollection = parts.ToLookup(p => p.CollectionId, p => p.ShowId); + var interstitialItems = + showIds.Count(rollers.Contains) + // Коллекция считается роликовой, только если из роликов собрана целиком: одна серия + // внутри франшизы делает её обычным контентом. + + collectionIds.Count(id => + byCollection[id].Any() && byCollection[id].All(rollers.Contains) + ); + + // Ровно половина уже считается роликовой: смешанная группа в слоте ведёт себя так же плохо, + // а собрать её осмысленно нельзя — это либо недоразумение, либо забытая позиция. + return interstitialItems * 2 >= items.Count; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs index 3bcc63f..46824cc 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Groups; using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Templates.Layers; @@ -82,8 +83,10 @@ public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext) } } -public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext) - : ICommandHandler +public sealed class UpdateTemplateCommandHandler( + IAppDbContext dbContext, + InterstitialGroups interstitials +) : ICommandHandler { public async Task Handle( UpdateTemplateCommand command, @@ -97,11 +100,17 @@ public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext) if (template is null) return Result.Failure(TemplateErrors.NotFound); - if ( - command.FallbackGroupId is { } groupId - && !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken) - ) - return Result.Failure(TemplateErrors.GroupNotFound); + if (command.FallbackGroupId is { } groupId) + { + if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)) + return Result.Failure(TemplateErrors.GroupNotFound); + + // Аварийная группа закрывает каждую паузу: остаток слота, добор до якоря, пустой повтор. + // Из роликов она превращает всё это в рекламу — ровно то, что видно в расписании + // строками «аварийный запас» подряд. + if (await interstitials.IsInterstitialAsync(groupId, cancellationToken)) + return Result.Failure(TemplateErrors.InterstitialFallbackGroup); + } template.Rename(command.Name); template.SetFallbackGroup(command.FallbackGroupId); diff --git a/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs index 333574f..07ab2b0 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/SlotWriter.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Groups; using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Templates; @@ -9,7 +10,7 @@ namespace TeleWave.Application.Programming.Templates; /// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что /// им нужны соседние слоты и справочник групп. /// -public sealed class SlotWriter(IAppDbContext dbContext) +public sealed class SlotWriter(IAppDbContext dbContext, InterstitialGroups interstitials) { /// /// Проверяет вход и применяет его к слоту. = null — проверка перед @@ -45,6 +46,11 @@ public sealed class SlotWriter(IAppDbContext dbContext) || await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken); if (!known) return Result.Failure(TemplateErrors.GroupNotFound); + + // Ролики в слоте — не вкусовщина, а поломка эфира: планировщик поставит их подряд + // как программы, и зритель получит полосу рекламы вместо передачи. + if (await interstitials.IsInterstitialAsync(groupId, cancellationToken)) + return Result.Failure(TemplateErrors.InterstitialGroupInSlot); } if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null) diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs index ebf0b13..5839d47 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs @@ -39,6 +39,18 @@ public static class TemplateErrors "Группа не найдена." ); + public static readonly Error InterstitialGroupInSlot = Error.Validation( + "Templates.InterstitialGroupInSlot", + "В слоте группа роликов — в эфир уйдёт полоса рекламы, подписанная как программа. " + + "Ролики ставятся врезками стыка, а не слотом." + ); + + public static readonly Error InterstitialFallbackGroup = Error.Validation( + "Templates.InterstitialFallbackGroup", + "Аварийной группой выбрана группа роликов — тогда каждая пауза в эфире станет рекламой. " + + "Выберите группу контента или снимите её вовсе." + ); + public static readonly Error JunctionNotFound = Error.NotFound( "Templates.JunctionNotFound", "Шаблон стыка не найден." diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs index c59229c..51b400c 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs @@ -131,7 +131,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa Culture, $"| {group.Name} | {KindName(group.DominantKind)} | {group.UnitCount} | " + $"{(int)Math.Round(group.AverageUnitMinutes)} мин | " - + $"{group.Strictest?.ToString() ?? "—"} | {genre ?? "—"} |" + + $"{AudienceName(group.Strictest)} | {genre ?? "—"} |" ); } } @@ -164,7 +164,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa Culture, $"| {show.Name} | {KindName(show.Kind)} | {show.Year?.ToString(Culture) ?? "—"} | " + $"{show.Units} | {show.AverageMinutes} мин | " - + $"{show.Audience?.ToString() ?? "—"} | {genre ?? "—"} |" + + $"{AudienceName(show.Audience)} | {genre ?? "—"} |" ); } @@ -186,6 +186,22 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa text.AppendLine(names.Count == 0 ? empty : string.Join(", ", names)); } + /// + /// Рейтинг ровно так, как он пишется в файле обмена («PG-13», не «Pg13»). Модель переписывает + /// в ответ то, что увидела в таблице, а формат читает написание с провода: разойдись эти два + /// написания — и правило детского времени из ответа не разберётся вовсе. + /// + private static string AudienceName(ShowAudience? audience) => + audience switch + { + ShowAudience.G => "G", + ShowAudience.Pg => "PG", + ShowAudience.Pg13 => "PG-13", + ShowAudience.R => "R", + ShowAudience.Nc17 => "NC-17", + _ => "—", + }; + /// Тип контента словами: «сериал», «полный метр» — так его читает модель. private static string KindName(ShowKind kind) => kind switch @@ -198,11 +214,16 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa /// /// Шоу, у которых есть что показывать. Считается по готовым медиа: шоу без обработанного файла /// в эфир не пойдёт, и предлагать его модели значит получить сетку под несуществующий контент. + /// + /// Ролики в список не идут: их бывают сотни, они съедают потолок перечисления, а главное — + /// поимённый список рекламы читается моделью как материал для слотов, и она честно собирает + /// из него группу. В эфире это полоса рекламы, подписанная как программа. /// private async Task> LoadShowsAsync(CancellationToken cancellationToken) { var shows = await dbContext .Shows.AsNoTracking() + .Where(s => s.Kind != ShowKind.Interstitial) .Include(s => s.Episodes) .ToListAsync(cancellationToken); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs index d6c398b..c5264e0 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/GridPromptText.cs @@ -29,8 +29,9 @@ public static class GridPromptText - Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет. - Стыки и заставки придумывать нельзя — только имена из списков ниже. - - Группы типа «ролик» — это реклама и джинглы для стыков. В слоты их ставить нельзя: - в эфир уйдёт полоса роликов, подписанная как программа. + - Группы типа «ролик» — это реклама и джинглы для стыков. Ни в слот, ни в fallbackGroup их + ставить нельзя: в эфир уйдёт полоса роликов, подписанная как программа. Рекламы в списке + библиотеки нет по той же причине — собирать группы из неё не нужно. - Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»). - weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs index 52e35bc..e022f1e 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs @@ -22,7 +22,8 @@ namespace TeleWave.Application.Programming.Templates.Transfer; public sealed class ImportGridCommandHandler( IAppDbContext dbContext, SlotWriter writer, - GroupStatsService stats + GroupStatsService stats, + InterstitialGroups interstitials ) : ICommandHandler> { public async Task> Handle( @@ -74,7 +75,7 @@ public sealed class ImportGridCommandHandler( skipped += dropped; } - Apply(template, command.Config, groups, junctions, warnings); + await ApplyAsync(template, command.Config, groups, junctions, warnings, cancellationToken); template.MarkChanged(); return Result.Success( @@ -303,12 +304,13 @@ public sealed class ImportGridCommandHandler( } /// Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале. - private static void Apply( + private async Task ApplyAsync( ScheduleTemplate template, GridConfig config, IReadOnlyDictionary groups, IReadOnlyDictionary junctions, - List warnings + List warnings, + CancellationToken cancellationToken ) { if (config.Rules is { } rules) @@ -318,7 +320,17 @@ public sealed class ImportGridCommandHandler( config.FallbackGroup is { Length: > 0 } fallback && Resolve(fallback, groups, "Группа", warnings) is { } groupId ) - template.SetFallbackGroup(groupId); + { + // Аварийной группой из роликов файл превращает в рекламу каждую паузу эфира. Замечанием, + // а не отказом: остальная сетка из файла корректна, и терять её из-за одного поля нельзя. + if (await interstitials.IsInterstitialAsync(groupId, cancellationToken)) + warnings.Add( + $"Аварийная группа «{fallback}» собрана из роликов — она не проставлена: " + + "паузы эфира стали бы рекламой." + ); + else + template.SetFallbackGroup(groupId); + } if ( config.DefaultJunction is { Length: > 0 } junction diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs index 499405a..66d4b44 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs @@ -416,7 +416,7 @@ public class GenerateGridTests var result = await new GenerateGridCommandHandler( db, Planner(db), - new SlotWriter(db) + GroupServices.Slots(db) ).Handle( new GenerateGridCommand( channelId, @@ -657,7 +657,7 @@ public class GenerateGridTests var result = await new GenerateGridCommandHandler( db, Planner(db), - new SlotWriter(db) + GroupServices.Slots(db) ).Handle( new GenerateGridCommand( channelId, @@ -709,7 +709,7 @@ public class GenerateGridTests var result = await new GenerateGridCommandHandler( db, Planner(db), - new SlotWriter(db) + GroupServices.Slots(db) ).Handle( new GenerateGridCommand( channelId, diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs index 7d7627a..5937ecf 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/GridTransferTests.cs @@ -111,10 +111,12 @@ public class GridTransferTests ) { await using var db = fixture.New(); - var result = await new ImportGridCommandHandler(db, new SlotWriter(db), Stats(db)).Handle( - new ImportGridCommand(channelId, config, replace), - CancellationToken.None - ); + var result = await new ImportGridCommandHandler( + db, + GroupServices.Slots(db), + Stats(db), + GroupServices.Interstitials(db) + ).Handle(new ImportGridCommand(channelId, config, replace), CancellationToken.None); Assert.True(result.IsSuccess); await db.SaveChangesAsync(CancellationToken.None); return result.Value; diff --git a/backend/tests/TeleWave.Application.Tests/Programming/InterstitialGroupGuardTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/InterstitialGroupGuardTests.cs new file mode 100644 index 0000000..6a5c089 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/InterstitialGroupGuardTests.cs @@ -0,0 +1,152 @@ +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.CreateSlot; +using TeleWave.Application.Programming.Templates.Layers; +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 InterstitialGroupGuardTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private sealed record World(TestDb Db, Guid LayerId, Guid TemplateId, Guid Ads, Guid Content); + + private static Show Ready(string name, ShowKind kind, List assets, int seconds) + { + var show = Show.Create(name, kind); + var asset = MediaAsset.Register($"{name}.mkv", ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + TimeSpan.FromSeconds(seconds), + 6, + 1000, + 1920, + 1080, + "h264", + "aac", + $"assets/{name}" + ) + ); + assets.Add(asset); + show.AddEpisode(asset.Id); + return show; + } + + private static async Task SeedAsync() + { + var fixture = new TestDb(); + var assets = new List(); + var shows = new List(); + + var ads = Group.Create("Реклама"); + for (var i = 0; i < 3; i++) + { + var clip = Ready($"Ролик {i}", ShowKind.Interstitial, assets, 20); + shows.Add(clip); + ads.AddElement(GroupElementKind.Show, clip.Id); + } + + var series = Ready("Сериал", ShowKind.Series, assets, 1320); + shows.Add(series); + var content = Group.Create("Сериалы"); + content.AddElement(GroupElementKind.Show, series.Id); + + var channel = Channel.Create("Первый", "one", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + var layer = template.AddLayer("Основная", 10); + channel.SetTemplate(template.Id); + + await using var seed = fixture.New(); + seed.MediaAssets.AddRange(assets); + seed.Shows.AddRange(shows); + seed.Groups.AddRange([ads, content]); + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + + return new World(fixture, layer.Id, template.Id, ads.Id, content.Id); + } + + private static SlotInput Input(Guid groupId) => + new( + "Блок", + null, + new TimeOnly(20, 0), + 60, + Daypart.Prime, + SlotKind.Content, + groupId, + null, + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext, + false, + 30, + null, + null, + null + ); + + [Fact] + public async Task Slot_WithInterstitialGroup_IsRejected() + { + var world = await SeedAsync(); + await using var db = world.Db.New(); + + var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle( + new CreateSlotCommand(world.LayerId, Input(world.Ads)), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(TemplateErrors.InterstitialGroupInSlot, result.Error); + } + + [Fact] + public async Task Slot_WithContentGroup_IsAccepted() + { + // Обратная сторона запрета: обычная группа обязана проходить, иначе признак «ролики» + // ловил бы всё подряд. + var world = await SeedAsync(); + await using var db = world.Db.New(); + + var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle( + new CreateSlotCommand(world.LayerId, Input(world.Content)), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task FallbackGroup_OfInterstitials_IsRejected() + { + // Аварийная группа закрывает каждую паузу эфира — из роликов она превращает в рекламу + // остаток каждого слота. + var world = await SeedAsync(); + await using var db = world.Db.New(); + + var result = await new UpdateTemplateCommandHandler( + db, + GroupServices.Interstitials(db) + ).Handle( + new UpdateTemplateCommand(world.TemplateId, "Сетка", world.Ads, null, null), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(TemplateErrors.InterstitialFallbackGroup, result.Error); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs index 0611b0d..fc425be 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs @@ -87,7 +87,7 @@ public class TemplateEditingTests private static async Task AddSlotAsync(Fixture f, SlotInput input) { await using var db = f.Db.New(); - var created = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + var created = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle( new CreateSlotCommand(f.LayerId, input), CancellationToken.None ); @@ -116,7 +116,7 @@ public class TemplateEditingTests var f = await SeedAsync(); await using var db = f.Db.New(); - var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle( new CreateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)), CancellationToken.None ); @@ -151,7 +151,7 @@ public class TemplateEditingTests var f = await SeedAsync(); await using var db = f.Db.New(); - var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle( new CreateSlotCommand(f.LayerId, Input(groupId: null)), CancellationToken.None ); @@ -165,7 +165,7 @@ public class TemplateEditingTests var f = await SeedAsync(); await using var db = f.Db.New(); - var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle( new CreateSlotCommand(f.LayerId, Input(groupId: Guid.NewGuid())), CancellationToken.None ); @@ -179,7 +179,7 @@ public class TemplateEditingTests var f = await SeedAsync(); await using var db = f.Db.New(); - var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle( new CreateSlotCommand(f.LayerId, Input(kind: SlotKind.Repeat)), CancellationToken.None ); @@ -211,7 +211,7 @@ public class TemplateEditingTests await AddSlotAsync(f, Input(groupId: f.GroupId)); await using var db = f.Db.New(); - var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle( + var result = await new CreateSlotCommandHandler(GroupServices.Slots(db)).Handle( new CreateSlotCommand( f.LayerId, Input(title: "Второй", start: new TimeOnly(21, 0), groupId: f.GroupId) @@ -228,7 +228,7 @@ public class TemplateEditingTests var f = await SeedAsync(); await using var db = f.Db.New(); - var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle( + var result = await new UpdateSlotCommandHandler(GroupServices.Slots(db)).Handle( new UpdateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)), CancellationToken.None ); @@ -245,7 +245,7 @@ public class TemplateEditingTests await using (var db = f.Db.New()) { // Сдвиг внутрь собственного интервала: слот не должен пересечься сам с собой. - var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle( + var result = await new UpdateSlotCommandHandler(GroupServices.Slots(db)).Handle( new UpdateSlotCommand( slotId, Input(title: "Ночное кино", start: new TimeOnly(21, 0), groupId: f.GroupId) @@ -270,7 +270,7 @@ public class TemplateEditingTests await using (var db = f.Db.New()) { - var missing = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle( + var missing = await new DeleteSlotCommandHandler(GroupServices.Slots(db)).Handle( new DeleteSlotCommand(Guid.NewGuid()), CancellationToken.None ); @@ -279,7 +279,7 @@ public class TemplateEditingTests await using (var db = f.Db.New()) { - var deleted = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle( + var deleted = await new DeleteSlotCommandHandler(GroupServices.Slots(db)).Handle( new DeleteSlotCommand(slotId), CancellationToken.None ); @@ -407,7 +407,10 @@ public class TemplateEditingTests var f = await SeedAsync(); await using var db = f.Db.New(); - var result = await new UpdateTemplateCommandHandler(db).Handle( + var result = await new UpdateTemplateCommandHandler( + db, + GroupServices.Interstitials(db) + ).Handle( new UpdateTemplateCommand(Guid.NewGuid(), "Сетка", null, null, null), CancellationToken.None ); @@ -421,7 +424,10 @@ public class TemplateEditingTests var f = await SeedAsync(); await using var db = f.Db.New(); - var result = await new UpdateTemplateCommandHandler(db).Handle( + var result = await new UpdateTemplateCommandHandler( + db, + GroupServices.Interstitials(db) + ).Handle( new UpdateTemplateCommand(f.TemplateId, "Сетка", Guid.NewGuid(), null, null), CancellationToken.None ); @@ -447,7 +453,10 @@ public class TemplateEditingTests await using (var db = f.Db.New()) { - var result = await new UpdateTemplateCommandHandler(db).Handle( + var result = await new UpdateTemplateCommandHandler( + db, + GroupServices.Interstitials(db) + ).Handle( new UpdateTemplateCommand(f.TemplateId, " Новая сетка ", f.GroupId, null, rules), CancellationToken.None ); diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs index 181d78b..fe52f86 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs @@ -63,8 +63,9 @@ public class TransferGuardsTests var config = new GridConfig([new GridConfigLayer("Основная сетка", 10, [])]); var result = await new ImportGridCommandHandler( db, - new SlotWriter(db), - GroupServices.Stats(db) + GroupServices.Slots(db), + GroupServices.Stats(db), + GroupServices.Interstitials(db) ).Handle(new ImportGridCommand(Guid.NewGuid(), config, false), CancellationToken.None); Assert.False(result.IsSuccess); diff --git a/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs b/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs index 9f10191..7696235 100644 --- a/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs +++ b/backend/tests/TeleWave.Application.Tests/Support/GroupServices.cs @@ -1,5 +1,6 @@ using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Templates; namespace TeleWave.Application.Tests.Support; @@ -14,4 +15,9 @@ internal static class GroupServices public static GroupStatsService Stats(IAppDbContext db) => new(db, new GroupElementResolver(db), Dynamic(db)); + + /// Писатель слотов: ему нужен ещё и признак «группа роликов» — их в слот не пускают. + public static InterstitialGroups Interstitials(IAppDbContext db) => new(db); + + public static SlotWriter Slots(IAppDbContext db) => new(db, Interstitials(db)); } diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 6bf1ef1..7874819 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -541,6 +541,15 @@ JunctionElement что есть у контента: остывание (не крутить один ролик дважды подряд), разные группы на утро и прайм, статистику. Библиотека фильтруется по типу, служебные ролики не мешают на экране шоу. +Обратная сторона этой экономии: группа роликов неотличима от группы сериалов — те же позиции, тот же +разворот, — и её можно поставить туда, где она означает поломку. **Ролики не идут ни в слот, ни +в аварийную группу**, и это проверяется на всех путях: ручная правка слота и правил канала отвечают +ошибкой, импорт файла — замечанием (одно поле не должно валить всю сетку), автосборка и запрос к ИИ +такую группу просто не предлагают. Иначе выходит полоса рекламы, подписанная в программе как +передача: планировщик честно ставит «единицы» подряд, а зритель видит сорок минут роликов. +Признак — по позициям, а не по единицам: сотня коротких роликов против одного длинного сериала +ничего не решает, а вот «половина позиций — ролики» решает. + **Рекламный блок — это коллекция.** Реклама здесь не служебный элемент, а половина обаяния: её помнят лучше передач. Поэтому важно уметь собрать блок целиком, а не только ротировать ролики по одному — и это получается само, без единой строчки специального кода: коллекция из шести `Interstitial`-шоу