diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs index 0b2518a..1e4e323 100644 --- a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs @@ -8,6 +8,7 @@ using TeleWave.Application.Programming.Templates.CopyTemplate; using TeleWave.Application.Programming.Templates.CreateSlot; using TeleWave.Application.Programming.Templates.CreateTemplate; using TeleWave.Application.Programming.Templates.DeleteSlot; +using TeleWave.Application.Programming.Templates.Generate; using TeleWave.Application.Programming.Templates.GetTemplate; using TeleWave.Application.Programming.Templates.Layers; using TeleWave.Application.Programming.Templates.UpdateSlot; @@ -62,6 +63,18 @@ public static class TemplateEndpoints ) .Produces(); + // Автосборка сетки: каталог профилей, план по выбранному и его создание. План считается + // одним и тем же кодом, поэтому предпросмотр показывает ровно то, что создаст кнопка. + admin + .MapGet("/grid-profiles", ListGridProfiles) + .Produces>(); + admin + .MapGet("/channels/{channelId:guid}/template/grid-plan", PreviewGrid) + .Produces(); + admin + .MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid) + .Produces(); + admin .MapPost("/templates/{templateId:guid}/layers", CreateLayer) .Produces(StatusCodes.Status201Created); @@ -158,6 +171,44 @@ public static class TemplateEndpoints return result.ToHttpResult(); } + private static async Task ListGridProfiles( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ListGridProfilesQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task PreviewGrid( + Guid channelId, + GridProfileKind profile, + GridGenerationMode mode, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new PreviewGeneratedGridQuery(channelId, profile, mode), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task GenerateGrid( + Guid channelId, + GenerateGridBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new GenerateGridCommand(channelId, body.Profile, body.Mode), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task CopyTemplate( Guid channelId, Guid targetChannelId, @@ -286,6 +337,8 @@ public sealed record UpdateTemplateBody( PlanningRules? Rules ); +public sealed record GenerateGridBody(GridProfileKind Profile, GridGenerationMode Mode); + public sealed record CreateLayerBody(string Name, int Priority); public sealed record UpdateLayerBody( diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingLimits.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingLimits.cs new file mode 100644 index 0000000..a81bdbd --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaProcessingLimits.cs @@ -0,0 +1,14 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Пропускная способность обработчика медиа. Нужна, чтобы по очереди и среднему времени посчитать, +/// когда она разгребётся: при двух параллельных транскодах ждать вдвое меньше, чем при одном. +/// +/// Порт, а не чтение настроек напрямую: настройки ffmpeg живут в Infrastructure, и прикладной слой +/// про их устройство знать не должен. +/// +public interface IMediaProcessingLimits +{ + /// Сколько файлов обрабатывается одновременно. + int MaxParallelTranscodes { get; } +} diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index c34d8b2..e979228 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -12,6 +12,7 @@ using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups.Suggest; using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.Generate; namespace TeleWave.Application; @@ -47,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/Media/Stats/GetMediaStatsQuery.cs b/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQuery.cs index 2668964..3c984bc 100644 --- a/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQuery.cs +++ b/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQuery.cs @@ -8,4 +8,13 @@ public sealed record GetMediaStatsQuery : IQuery; /// Ассетов в статусе Pending (ждут обработки) сейчас. /// Ассетов в статусе Processing (обрабатываются) сейчас. /// Среднее время обработки по недавним завершённым (Ready), сек; null — если нет данных. -public sealed record MediaStatsDto(int Queued, int Processing, double? AverageProcessingSeconds); +/// +/// Оценка времени до конца очереди, сек: работа, оставшаяся по всем ждущим и уже начатым ассетам, +/// делённая на число параллельных транскодов. null — очередь пуста либо среднее ещё не набралось. +/// +public sealed record MediaStatsDto( + int Queued, + int Processing, + double? AverageProcessingSeconds, + double? EstimatedRemainingSeconds +); diff --git a/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQueryHandler.cs b/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQueryHandler.cs index ceb46e4..c8d7444 100644 --- a/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQueryHandler.cs @@ -5,8 +5,10 @@ using TeleWave.Domain.Media; namespace TeleWave.Application.Media.Stats; -public sealed class GetMediaStatsQueryHandler(IAppDbContext dbContext) - : IQueryHandler +public sealed class GetMediaStatsQueryHandler( + IAppDbContext dbContext, + IMediaProcessingLimits limits +) : IQueryHandler { /// По скольким последним завершённым ассетам усредняем время обработки. private const int AverageSample = 500; @@ -40,6 +42,48 @@ public sealed class GetMediaStatsQueryHandler(IAppDbContext dbContext) .ToListAsync(cancellationToken); double? average = recent.Count > 0 ? recent.Average(d => d.TotalSeconds) : null; - return new MediaStatsDto(queued, processing, average); + var estimate = await EstimateRemainingAsync( + assets, + queued, + processing, + average, + cancellationToken + ); + + return new MediaStatsDto(queued, processing, average, estimate); + } + + /// + /// Сколько ещё крутиться очереди. Считается как суммарная оставшаяся работа, делённая на число + /// параллельных транскодов: у ждущих в запасе целое среднее, у уже начатых — среднее минус то, + /// что они уже отработали. + /// + /// Оценка, а не обещание: длинный фильм после десятка роликов сдвинет её вверх. Но и без неё + /// «492 в очереди» не говорит администратору ничего — это может быть и час, и трое суток. + /// + private async Task EstimateRemainingAsync( + IQueryable assets, + int queued, + int processing, + double? average, + CancellationToken cancellationToken + ) + { + if (average is not { } avg || queued + processing == 0) + return null; + + var startedAt = await assets + .Where(x => x.Status == MediaAssetStatus.Processing && x.ProcessingStartedAt != null) + .Select(x => x.ProcessingStartedAt!.Value) + .ToListAsync(cancellationToken); + + var now = DateTimeOffset.UtcNow; + var inFlight = startedAt.Sum(started => Math.Max(0, avg - (now - started).TotalSeconds)); + + // Ассет в обработке без отметки старта (пережил перезапуск) считаем целой работой: занизить + // ожидание хуже, чем завысить. + var unknown = (processing - startedAt.Count) * avg; + + return (queued * avg + inFlight + unknown) / limits.MaxParallelTranscodes; } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommand.cs new file mode 100644 index 0000000..2e359f2 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommand.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Generate; + +/// +/// Собрать сетку по профилю. План не передаётся с клиента: команда пересчитывает его сама на свежих +/// данных — за время, пока админ смотрел предпросмотр, состав групп мог измениться. +/// +public sealed record GenerateGridCommand( + Guid ChannelId, + GridProfileKind Profile, + GridGenerationMode Mode +) : ICommand>; + +public sealed class GenerateGridCommandValidator : AbstractValidator +{ + public GenerateGridCommandValidator() + { + RuleFor(x => x.Profile).IsInEnum(); + RuleFor(x => x.Mode).IsInEnum(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommandHandler.cs new file mode 100644 index 0000000..8d09d77 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GenerateGridCommandHandler.cs @@ -0,0 +1,106 @@ +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.Generate; + +/// +/// Создаёт слоты по плану. Слои заводятся по именам профиля и переиспользуются: повторная генерация +/// не должна плодить «Основная сетка (2)». +/// +/// Эфир при этом не двигается — как и любая правка сетки, генерация только помечает шаблон +/// изменённым, а хвост пересобирает отдельная кнопка применения. +/// +public sealed class GenerateGridCommandHandler( + IAppDbContext dbContext, + GridPlanner planner, + SlotWriter writer +) : ICommandHandler> +{ + /// Приоритеты слоёв генератора: выходные обязаны перекрывать будни. + private const int MainPriority = 10; + private const int WeekendPriority = 20; + + public async Task> Handle( + GenerateGridCommand command, + CancellationToken cancellationToken + ) + { + var planned = await planner.BuildAsync( + command.ChannelId, + command.Profile, + command.Mode, + cancellationToken + ); + if (!planned.IsSuccess) + return Result.Failure(planned.Error); + + var plan = planned.Value; + + 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 removed = 0; + if (command.Mode == GridGenerationMode.Rebuild) + foreach (var layer in template.Layers) + foreach (var slot in layer.Slots.ToList()) + if (layer.RemoveSlot(slot.Id)) + removed++; + + var created = 0; + foreach (var slot in plan.Slots) + { + var layer = EnsureLayer(template, slot.Layer); + var applied = await writer.ApplyAsync(layer, null, ToInput(slot), cancellationToken); + if (!applied.IsSuccess) + return Result.Failure(applied.Error); + created++; + } + + // Аварийная группа — то, чем закрываются паузы между слотами. Без неё в ленте остались бы + // дыры, поэтому генератор проставляет её, если админ ещё не выбрал свою. + if (plan.FallbackGroupId is { } fallbackGroupId && template.FallbackGroupId is null) + template.SetFallbackGroup(fallbackGroupId); + + template.MarkChanged(); + return Result.Success(new GenerateGridResultDto(created, removed)); + } + + private static GridLayer EnsureLayer(ScheduleTemplate template, GridPlanLayer kind) + { + var (name, priority) = + kind == GridPlanLayer.Weekend + ? (GridPlanner.WeekendLayerName, WeekendPriority) + : (GridPlanner.MainLayerName, MainPriority); + + return template.Layers.FirstOrDefault(l => !l.IsBackground && l.Name == name) + ?? template.AddLayer(name, priority); + } + + private static SlotInput ToInput(PlannedSlot slot) => + new( + slot.Title, + slot.Weekday, + slot.Start, + slot.DurationMinutes, + slot.Daypart, + slot.SlotKind, + slot.GroupId, + slot.Strategy, + slot.RepeatSource, + slot.BlockMode, + slot.BlockValue, + OverflowPolicy.ContinueNext, + slot.IsAnchor, + Slot.DefaultMaxDriftMinutes, + null + ); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs new file mode 100644 index 0000000..5fe6fc4 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs @@ -0,0 +1,61 @@ +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Generate; + +/// Что делать с тем, что в сетке уже есть. +public enum GridGenerationMode +{ + /// Только незакрытые интервалы; ручные слоты не трогаются. + FillGaps = 0, + + /// Снести все слоты шаблона и построить неделю заново. + Rebuild = 1, +} + +/// Куда ложится слот. Имена слоёв — в . +public enum GridPlanLayer +{ + Main = 0, + Weekend = 1, +} + +/// +/// Будущий слот: то же, что , но без слоя и идентификаторов. Сравнивается +/// по значению — на этом держится схлопывание одинаковых дней в один слот «каждый день». +/// +public sealed record PlannedSlot( + GridPlanLayer Layer, + int? Weekday, + TimeOnly Start, + int DurationMinutes, + string Title, + Daypart Daypart, + SlotKind SlotKind, + Guid? GroupId, + string? GroupName, + SlotBlockMode BlockMode, + int BlockValue, + SlotStrategy? Strategy, + RepeatSource? RepeatSource, + /// Жёсткий старт. Ставится в начале прайма: кино должно начинаться в объявленное время. + bool IsAnchor +); + +/// +/// Результат разбора: что будет создано, что снесено и о чём стоит знать заранее. Считается и для +/// предпросмотра, и для самой генерации — одним и тем же кодом, поэтому показанное совпадает +/// с созданным. +/// +/// Сколько минут недели генератор считал свободными. +/// Сколько из них закрыто слотами плана. +public sealed record GridPlan( + GridProfile Profile, + GridGenerationMode Mode, + IReadOnlyList Slots, + IReadOnlyList Notes, + int SlotsToRemove, + Guid? FallbackGroupId, + string? FallbackGroupName, + int FreeMinutes, + int CoveredMinutes +); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanDtos.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanDtos.cs new file mode 100644 index 0000000..4de6c07 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanDtos.cs @@ -0,0 +1,84 @@ +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Generate; + +/// Профиль в списке выбора: имя, на что похоже и для какой библиотеки годится. +public sealed record GridProfileDto( + GridProfileKind Kind, + string Name, + string Reference, + string Description +); + +/// Строка предпросмотра — будущий слот так, как его покажут админу. +/// Человеческое описание блока: «4 подряд», «до конца слота». +public sealed record GridPlanSlotDto( + GridPlanLayer Layer, + int? Weekday, + string Start, + int DurationMinutes, + string Title, + Daypart Daypart, + SlotKind SlotKind, + string? GroupName, + string Block +); + +/// +/// План генерации целиком. больше нуля только в режиме пересборки — +/// это то, что будет снесено, и показать это до нажатия обязательно. +/// +public sealed record GridPlanDto( + GridProfileKind Profile, + string ProfileName, + IReadOnlyList Slots, + IReadOnlyList Notes, + int SlotsToRemove, + string? FallbackGroupName, + int FreeMinutes, + int CoveredMinutes +); + +public sealed record GenerateGridResultDto(int Created, int Removed); + +/// Перевод плана в то, что уезжает на клиент. Общий для предпросмотра и результата. +public static class GridPlanMapper +{ + public static GridPlanDto ToDto(this GridPlan plan) => + new( + plan.Profile.Kind, + plan.Profile.Name, + [.. plan.Slots.Select(ToDto)], + plan.Notes, + plan.SlotsToRemove, + plan.FallbackGroupName, + plan.FreeMinutes, + plan.CoveredMinutes + ); + + private static GridPlanSlotDto ToDto(PlannedSlot slot) => + new( + slot.Layer, + slot.Weekday, + $"{slot.Start.Hour:00}:{slot.Start.Minute:00}", + slot.DurationMinutes, + slot.Title, + slot.Daypart, + slot.SlotKind, + slot.GroupName, + Block(slot) + ); + + private static string Block(PlannedSlot slot) => + slot.SlotKind switch + { + SlotKind.SignOff => "конец вещания", + SlotKind.Repeat => "повтор", + _ => slot.BlockMode switch + { + SlotBlockMode.Count => $"{slot.BlockValue} подряд", + SlotBlockMode.Duration => $"{slot.BlockValue} мин", + _ => "до конца слота", + }, + }; +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs new file mode 100644 index 0000000..88ca380 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs @@ -0,0 +1,492 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Groups; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Generate; + +/// +/// Раскладывает свободное время канала по полосам выбранного профиля и подбирает под каждую полосу +/// группу. Ничего не пишет: результат — план, который показывают в предпросмотре и по которому +/// потом создают слоты. Один и тот же расчёт для обеих операций — иначе показанное и созданное +/// разошлись бы. +/// +/// Подбор намеренно объяснимый: жёстко отсекается неподходящее по рейтингу, дальше баллы за тип +/// контента, длину единицы и запас серий, минус за повторное использование в тех же сутках. Админ +/// должен понимать, почему в прайм встала именно эта группа. +/// +public sealed class GridPlanner( + IAppDbContext dbContext, + DynamicGroupResolver dynamicResolver, + GroupElementResolver elementResolver +) +{ + public const string MainLayerName = "Основная сетка"; + public const string WeekendLayerName = "Выходные"; + + /// Слот короче этого смысла не имеет — остаток отдаётся соседнему слоту. + private const int MinSlotMinutes = 15; + + /// Длины слотов округляются до пяти минут: сетка должна читаться, а не считаться. + private const int SlotStepMinutes = 5; + + /// Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один. + private const int FeatureLengthMinutes = 60; + + public async Task> BuildAsync( + Guid channelId, + GridProfileKind profileKind, + GridGenerationMode mode, + CancellationToken cancellationToken + ) + { + var channel = await dbContext + .Channels.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == 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 == channelId, cancellationToken); + if (template is null) + return Result.Failure(ChannelErrors.TemplateNotFound); + + var groups = await LoadGroupsAsync(cancellationToken); + if (groups.Count == 0) + return Result.Failure(TemplateErrors.NoGroupsToGenerate); + + var profile = GridProfiles.Get(profileKind); + var run = new PlanRun(profile, groups, channel.DayStartTime); + + // Занятым считается время слотов всех слоёв, включая выключенные: место, которое админ + // отвёл под слот и временно погасил, — не дыра, и застраивать его генератору нечего. + var existing = template.Layers.SelectMany(l => l.Slots).ToList(); + + var slots = + mode == GridGenerationMode.Rebuild ? BuildWeek(run) : FillExistingGaps(run, existing); + + var freeMinutes = + mode == GridGenerationMode.Rebuild + ? 7 * GridCoverage.MinutesInDay + : GridCoverage + .Week.SelectMany(day => GridCoverage.Gaps(existing, day, run.DayStart)) + .Sum(gap => gap.To - gap.From); + + var fallback = groups.MaxBy(g => g.UnitCount); + var needsFallback = template.FallbackGroupId is null && fallback is not null; + if (needsFallback) + run.Note( + $"Аварийной группы у сетки не было — ею станет «{fallback!.Name}»: без неё паузы " + + "между слотами останутся пустыми." + ); + + var everyDaySpan = mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7; + var covered = slots.Sum(s => s.DurationMinutes * (s.Weekday is null ? everyDaySpan : 1)); + + return Result.Success( + new GridPlan( + profile, + mode, + slots, + run.Notes, + mode == GridGenerationMode.Rebuild ? existing.Count : 0, + needsFallback ? fallback!.Id : null, + needsFallback ? fallback!.Name : null, + freeMinutes, + covered + ) + ); + } + + /// + /// Неделя с нуля: будни ложатся слотами «каждый день» в основной слой, выходные — отдельным + /// слоем поверх. Слой выходных перекрывает будний целиком, поэтому расхождение по субботе + /// и воскресенью получается без дублирования всей недели по дням. + /// + private static List BuildWeek(PlanRun run) + { + var wholeDay = new List<(int From, int To)> { (0, GridCoverage.MinutesInDay) }; + var slots = FillDay(run.Profile.Weekdays, wholeDay, null, GridPlanLayer.Main, run); + + if (run.Profile.Weekend.Count == 0) + return slots; + + foreach (var weekday in new[] { 6, 0 }) + slots.AddRange( + FillDay(run.Profile.Weekend, wholeDay, weekday, GridPlanLayer.Weekend, run) + ); + + return slots; + } + + /// + /// Заполнение дыр: считается по каждому дню отдельно, поэтому слоты получают явный день недели. + /// Интервалы, свободные во все семь дней, схлопываются в один слот «каждый день» — иначе первая + /// же генерация на пустом канале выдала бы семь одинаковых столбцов. + /// + /// Полосы берутся будние на все семь дней: отдельные выходные строятся слоем поверх буднего, + /// а слой имеет смысл только когда он покрывает день целиком. Заплатка в дыре этого не даёт, + /// поэтому выходные — дело режима пересборки. + /// + private static List FillExistingGaps(PlanRun run, IReadOnlyList existing) + { + var entries = new List<(int Weekday, PlannedSlot Slot)>(); + + foreach (var weekday in GridCoverage.Week) + { + var gaps = GridCoverage.Gaps(existing, weekday, run.DayStart); + foreach ( + var slot in FillDay(run.Profile.Weekdays, gaps, weekday, GridPlanLayer.Main, run) + ) + entries.Add((weekday, slot)); + } + + return Collapse(entries); + } + + private static List Collapse(List<(int Weekday, PlannedSlot Slot)> entries) + { + var result = new List(); + + foreach (var same in entries.GroupBy(e => e.Slot with { Weekday = null })) + { + var days = same.Select(e => e.Weekday).Distinct().Count(); + if (days == GridCoverage.Week.Length) + result.Add(same.Key); + else + result.AddRange(same.Select(e => e.Slot)); + } + + return result; + } + + /// Раскладывает свободные интервалы одних суток по полосам профиля. + private static List FillDay( + IReadOnlyList bands, + IReadOnlyList<(int From, int To)> free, + int? weekday, + GridPlanLayer layer, + PlanRun run + ) + { + // Счётчик использований живёт внутри суток: группа, занятая утром, уступает вечером другой, + // но на следующий день снова доступна — иначе раскладка зависела бы от порядка дней. + var used = new Dictionary(); + var slots = new List(); + + foreach (var band in bands) + foreach (var (bandFrom, bandTo) in BandPieces(band, run.DayStart)) + foreach (var (freeFrom, freeTo) in free) + { + var from = Math.Max(bandFrom, freeFrom); + var to = Math.Min(bandTo, freeTo); + if (to - from < MinSlotMinutes) + continue; + + slots.AddRange(FillPiece(band, from, to, weekday, layer, run, used)); + } + + return [.. slots.OrderBy(s => GridCoverage.OffsetInDay(s.Start, run.DayStart))]; + } + + /// + /// Полоса в смещениях от начала вещательных суток. Начало суток канала произвольно, поэтому + /// полоса может перевалить через их конец — тогда она разрезается на два куска. + /// + private static IEnumerable<(int From, int To)> BandPieces(GridBand band, TimeOnly dayStart) + { + var from = GridCoverage.OffsetInDay(band.From, dayStart); + var to = from + band.LengthMinutes; + + if (to <= GridCoverage.MinutesInDay) + { + yield return (from, to); + yield break; + } + + yield return (from, GridCoverage.MinutesInDay); + yield return (0, to - GridCoverage.MinutesInDay); + } + + /// Режет кусок полосы на слоты целевой длины и подбирает каждому наполнение. + private static IEnumerable FillPiece( + GridBand band, + int from, + int to, + int? weekday, + GridPlanLayer layer, + PlanRun run, + Dictionary used + ) + { + var length = to - from; + var count = + band.BlockMinutes <= 0 + ? 1 + : Math.Max( + 1, + (int) + Math.Round( + length / (double)band.BlockMinutes, + MidpointRounding.AwayFromZero + ) + ); + + var step = Math.Max(MinSlotMinutes, length / count / SlotStepMinutes * SlotStepMinutes); + var cursor = from; + + while (cursor < to) + { + var duration = Math.Min(step, to - cursor); + // Хвост короче минимального слота приклеиваем к текущему: два слота по семь минут + // читаются как ошибка сетки, а не как решение. + if (to - cursor - duration < MinSlotMinutes) + duration = to - cursor; + + // Якорь — только на первом слоте прайма: жёсткий старт у каждого слота подряд заставил + // бы генератор крутить фон перед каждым из них. + var anchor = cursor == from && band.Daypart == Daypart.Prime; + var slot = Compose(band, weekday, layer, cursor, duration, anchor, run, used); + if (slot is not null) + yield return slot; + + cursor += duration; + } + } + + private static PlannedSlot? Compose( + GridBand band, + int? weekday, + GridPlanLayer layer, + int offset, + int duration, + bool anchor, + PlanRun run, + Dictionary used + ) + { + var start = GridCoverage.AtOffset(offset, run.DayStart); + + if (band.SlotKind == SlotKind.SignOff) + return New(band.Title, null, SlotBlockMode.FillSlot, 1, null, null); + + if (band.SlotKind == SlotKind.Repeat) + return New( + band.Title, + null, + SlotBlockMode.FillSlot, + 1, + null, + new RepeatSource(band.RepeatDaysAgo, band.RepeatFrom, duration) + ); + + var group = Pick(band, run, used); + if (group is null) + { + run.Note( + $"Полоса «{band.Title}» ({band.From:HH\\:mm}–{band.To:HH\\:mm}): подходящей группы " + + "нет — интервал останется незакрытым." + ); + return null; + } + + used[group.Id] = used.GetValueOrDefault(group.Id) + 1; + run.WarnIfThin(band, group, weekday); + + var blockMode = band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot; + return New( + group.Name, + group.Id, + blockMode, + Math.Max(1, band.UnitsPerBlock), + new SlotStrategy(band.Strategy, RestartOnEnd: true, CooldownDays: band.CooldownDays), + null + ); + + PlannedSlot New( + string title, + Guid? groupId, + SlotBlockMode blockMode, + int blockValue, + SlotStrategy? strategy, + RepeatSource? repeat + ) => + new( + layer, + weekday, + start, + duration, + title, + band.Daypart, + band.SlotKind, + groupId, + groupId is null ? null : title, + blockMode, + blockValue, + strategy, + repeat, + anchor + ); + } + + /// + /// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной + /// взрослый блок без взрослого содержимого не имеет смысла. Остальное — баллы. + /// + private static GroupCandidate? Pick(GridBand band, PlanRun run, Dictionary used) => + run + .Groups.Where(g => Allowed(band, g)) + .OrderByDescending(g => Score(band, g, used)) + .ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase) + .FirstOrDefault(); + + private static bool Allowed(GridBand band, GroupCandidate group) + { + // Группа без рейтингов проходит любой потолок — так же ведут себя фильтр набора и + // планировщик: источники проставляют рейтинг далеко не всему. + if (band.MaxAudience is { } max && group.Strictest is { } strictest && strictest > max) + return false; + + return band.MinAudience is not { } min + || (group.Strictest is { } audience && audience >= min); + } + + private static int Score(GridBand band, GroupCandidate group, Dictionary used) + { + var score = Math.Min(group.UnitCount, 60); + + if (band.PreferKind is { } kind) + score += group.DominantKind == kind ? 100 : -40; + + var wantsFeature = band.PreferKind == ShowKind.Single; + var isFeature = group.AverageUnitMinutes >= FeatureLengthMinutes; + if (band.PreferKind is not null && wantsFeature == isFeature) + score += 30; + + // Одна и та же группа во всех полосах суток — формально валидная, но бессмысленная сетка. + return score - used.GetValueOrDefault(group.Id) * 50; + } + + 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 units = 0; + var duration = TimeSpan.Zero; + var seriesUnits = 0; + var singleUnits = 0; + ShowAudience? strictest = null; + + foreach (var element in composition) + { + if (!info.TryGetValue((element.Kind, element.Id), out var resolved)) + continue; + + units += resolved.UnitCount; + duration += resolved.TotalDuration; + + // У коллекции своего типа нет: франшиза — это почти всегда полнометражки. + if (resolved.ShowKind == ShowKind.Series) + seriesUnits += resolved.UnitCount; + else + singleUnits += resolved.UnitCount; + + if ( + resolved.Audience is { } audience + && (strictest is null || audience > strictest) + ) + strictest = audience; + } + + // Группа без готовых единиц в эфир не годится: слот встал бы, а место закрыл фон. + if (units == 0) + continue; + + candidates.Add( + new GroupCandidate( + group.Id, + group.Name, + units, + strictest, + seriesUnits >= singleUnits ? ShowKind.Series : ShowKind.Single, + duration.TotalMinutes / units + ) + ); + } + + return candidates; + } + + /// Группа глазами раскладки: чем её можно закрыть и насколько её хватит. + private sealed record GroupCandidate( + Guid Id, + string Name, + int UnitCount, + ShowAudience? Strictest, + ShowKind DominantKind, + double AverageUnitMinutes + ); + + /// Общее состояние одного разбора: профиль, кандидаты и накопленные замечания. + private sealed class PlanRun( + GridProfile profile, + IReadOnlyList groups, + TimeOnly dayStart + ) + { + private readonly List _notes = []; + private readonly HashSet _seen = []; + + public GridProfile Profile { get; } = profile; + public IReadOnlyList Groups { get; } = groups; + public TimeOnly DayStart { get; } = dayStart; + public IReadOnlyList Notes => _notes; + + public void Note(string text) + { + if (_seen.Add(text)) + _notes.Add(text); + } + + /// + /// Предупреждение о нехватке состава — то же, о чём потом скажут проверки сетки. Показать + /// это до создания слотов честнее, чем дать админу нажать кнопку и получить список ошибок. + /// + public void WarnIfThin(GridBand band, GroupCandidate group, int? weekday) + { + var perWeek = (weekday is null ? 7 : 1) * Math.Max(1, band.UnitsPerBlock); + if (group.UnitCount >= perWeek) + return; + + Note( + $"В группе «{group.Name}» {group.UnitCount} единиц при {perWeek} выходах в неделю " + + $"в полосе «{band.Title}» — повторы пойдут чаще, чем раз в неделю." + ); + } + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs new file mode 100644 index 0000000..9647f0f --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs @@ -0,0 +1,69 @@ +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Generate; + +/// Архетип сетки. Значения пишутся в запросы клиентом, поэтому переименованию не подлежат. +public enum GridProfileKind +{ + Mixed = 0, + Animation = 1, + Sitcom = 2, + Music = 3, + Movies = 4, + Kids = 5, +} + +/// +/// Полоса вещательных суток — «утренний блок», «прайм», «ночь». Описывает, чем полосу закрывать и +/// каким куском: длина слота, сколько единиц он берёт за выход и по какой стратегии выбирает. +/// +/// Времена — часы канала (не UTC и не смещения): профиль пишется так, как его читает человек, +/// а перевод в смещения от начала вещательных суток делает планировщик. +/// +/// Целевая длина одного слота внутри полосы; 0 — полоса одним слотом. +/// Сколько единиц берёт слот за выход; 0 — сколько влезет в бюджет. +/// Нижняя граница рейтинга: так задаются ночные взрослые блоки. +public sealed record GridBand( + TimeOnly From, + TimeOnly To, + Daypart Daypart, + string Title, + ShowKind? PreferKind = null, + ShowAudience? MaxAudience = null, + ShowAudience? MinAudience = null, + int BlockMinutes = 120, + int UnitsPerBlock = 0, + SlotStrategyType Strategy = SlotStrategyType.Sequential, + int CooldownDays = 0, + SlotKind SlotKind = SlotKind.Content, + int RepeatDaysAgo = 1, + TimeOnly RepeatFrom = default +) +{ + /// Длина полосы в минутах; полоса через полночь считается вперёд, а не назад. + public int LengthMinutes + { + get + { + var length = (int)(To.ToTimeSpan() - From.ToTimeSpan()).TotalMinutes; + return length > 0 ? length : length + GridCoverage.MinutesInDay; + } + } +} + +/// +/// Готовый профиль сетки: как выглядят вещательные сутки у канала такого типа. Профили списаны +/// с реальных каналов намеренно — «как у 2×2» админ проверяет по памяти, а «универсальный +/// алгоритм раскладки» проверить нельзя никак. +/// +/// пуст — выходные идут по будним полосам. +/// +public sealed record GridProfile( + GridProfileKind Kind, + string Name, + string Reference, + string Description, + IReadOnlyList Weekdays, + IReadOnlyList Weekend +); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs new file mode 100644 index 0000000..892804e --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs @@ -0,0 +1,584 @@ +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates.Generate; + +/// +/// Каталог профилей сетки. Списаны с реальных каналов: у каждого свой ритм — длина блока, что +/// стоит в прайме, чем закрыта ночь. Это данные, а не алгоритм: чтобы добавить профиль, достаточно +/// описать полосы суток. +/// +/// Полосы каждого профиля обязаны покрывать сутки целиком (06:00 → 06:00 следующих) — генератор +/// раскладывает по ним свободное время, и незакрытая полосой минута останется дырой в сетке. +/// +public static class GridProfiles +{ + public static IReadOnlyList All { get; } = + [Mixed(), Animation(), Sitcom(), Music(), Movies(), Kids()]; + + public static GridProfile Get(GridProfileKind kind) => + All.FirstOrDefault(p => p.Kind == kind) ?? All[0]; + + private static TimeOnly At(int hour, int minute = 0) => new(hour, minute); + + /// Ночной взрослый блок — общая концовка суток почти у всех профилей. + private static GridBand AdultNight(TimeOnly from, TimeOnly to) => + new( + from, + to, + Daypart.Night, + "Ночной блок 18+", + MinAudience: ShowAudience.R, + BlockMinutes: 180, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 7 + ); + + private static GridProfile Mixed() => + new( + GridProfileKind.Mixed, + "Универсальная сетка", + "как у обычного эфирного канала", + "Сериалы днём, полнометражка в прайм, взрослое после полуночи. Подходит, если " + + "библиотека смешанная и канал ни на чём не специализируется.", + [ + new( + At(6), + At(10), + Daypart.Morning, + "Утро", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 3 + ), + new( + At(10), + At(14), + Daypart.Day, + "День", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 3 + ), + new( + At(14), + At(18), + Daypart.Day, + "Дневной блок", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 3 + ), + new( + At(18), + At(20), + Daypart.Prime, + "Ранний прайм", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 2 + ), + new( + At(20), + At(23), + Daypart.Prime, + "Прайм", + ShowKind.Single, + BlockMinutes: 150, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 14 + ), + AdultNight(At(23), At(2)), + new(At(2), At(6), Daypart.Night, "Ночной эфир", BlockMinutes: 240), + ], + [ + new( + At(6), + At(10), + Daypart.Morning, + "Утро выходного", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 120, + UnitsPerBlock: 4 + ), + new( + At(10), + At(14), + Daypart.Day, + "Дневной марафон", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 240 + ), + new( + At(14), + At(18), + Daypart.Day, + "Дневное кино", + ShowKind.Single, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 14 + ), + new( + At(18), + At(23), + Daypart.Prime, + "Кино в прайм", + ShowKind.Single, + BlockMinutes: 150, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 21 + ), + AdultNight(At(23), At(2)), + new(At(2), At(6), Daypart.Night, "Ночной эфир", BlockMinutes: 240), + ] + ); + + private static GridProfile Animation() => + new( + GridProfileKind.Animation, + "Мультсериалы и аниме", + "как у 2×2", + "Длинные блоки по 4–5 серий подряд, утром — повтор вчерашнего вечера, после 23:00 " + + "взрослый блок. Нужен запас серий: сетка ест их быстро.", + [ + new( + At(6), + At(10), + Daypart.Morning, + "Утренний повтор", + SlotKind: SlotKind.Repeat, + BlockMinutes: 0, + RepeatDaysAgo: 1, + RepeatFrom: At(19) + ), + new( + At(10), + At(14), + Daypart.Day, + "Дневной блок", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 4 + ), + new( + At(14), + At(19), + Daypart.Day, + "Дневной марафон", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 150, + UnitsPerBlock: 5 + ), + new( + At(19), + At(23), + Daypart.Prime, + "Прайм", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 4 + ), + AdultNight(At(23), At(2)), + new(At(2), At(6), Daypart.Night, "Ночь", ShowKind.Series, BlockMinutes: 240), + ], + [ + new( + At(6), + At(12), + Daypart.Morning, + "Утренний марафон", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 180, + UnitsPerBlock: 6 + ), + new( + At(12), + At(19), + Daypart.Day, + "Дневной марафон", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 210 + ), + new( + At(19), + At(23), + Daypart.Prime, + "Прайм", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 4 + ), + AdultNight(At(23), At(3)), + new(At(3), At(6), Daypart.Night, "Ночь", ShowKind.Series, BlockMinutes: 180), + ] + ); + + private static GridProfile Sitcom() => + new( + GridProfileKind.Sitcom, + "Ситкомы", + "как у Paramount Comedy", + "Полосы получасовых серий по 3–4 подряд весь день, комедийная полнометражка вечером, " + + "ночью — взрослый юмор.", + [ + new( + At(6), + At(12), + Daypart.Morning, + "Утренние ситкомы", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 90, + UnitsPerBlock: 3 + ), + new( + At(12), + At(18), + Daypart.Day, + "Дневные ситкомы", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 90, + UnitsPerBlock: 3 + ), + new( + At(18), + At(22), + Daypart.Prime, + "Вечерние ситкомы", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 4 + ), + new( + At(22), + At(0), + Daypart.Prime, + "Комедия в прайм", + ShowKind.Single, + BlockMinutes: 120, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 14 + ), + AdultNight(At(0), At(6)), + ], + [ + new( + At(6), + At(14), + Daypart.Morning, + "Марафон ситкома", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 240 + ), + new( + At(14), + At(20), + Daypart.Day, + "Дневные ситкомы", + ShowKind.Series, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 4 + ), + new( + At(20), + At(0), + Daypart.Prime, + "Комедийное кино", + ShowKind.Single, + BlockMinutes: 120, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 21 + ), + AdultNight(At(0), At(6)), + ] + ); + + private static GridProfile Music() => + new( + GridProfileKind.Music, + "Ротация", + "как у MTV", + "Длинные полосы без жёсткой структуры: случайный выбор с остыванием, блоки по 3–4 часа. " + + "Для клипов, коротких роликов и реалити.", + [ + new( + At(6), + At(10), + Daypart.Morning, + "Утренняя ротация", + BlockMinutes: 240, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 2 + ), + new( + At(10), + At(14), + Daypart.Day, + "Дневная ротация", + BlockMinutes: 240, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 2 + ), + new( + At(14), + At(18), + Daypart.Day, + "Дневной блок", + BlockMinutes: 240, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 2 + ), + new( + At(18), + At(22), + Daypart.Prime, + "Вечерний блок", + BlockMinutes: 240, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 3 + ), + new( + At(22), + At(2), + Daypart.Night, + "Ночная ротация", + BlockMinutes: 240, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 1 + ), + new( + At(2), + At(6), + Daypart.Night, + "Ночь", + BlockMinutes: 240, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 1 + ), + ], + [] + ); + + private static GridProfile Movies() => + new( + GridProfileKind.Movies, + "Кинопоказ", + "как у киноканалов", + "Двухчасовые слоты под полнометражки круглые сутки, в прайме — длиннее, ночью строже " + + "по рейтингу. Сериалы такой сеткой не показать.", + [ + new( + At(6), + At(12), + Daypart.Morning, + "Утреннее кино", + ShowKind.Single, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 21 + ), + new( + At(12), + At(18), + Daypart.Day, + "Дневное кино", + ShowKind.Single, + ShowAudience.Pg13, + BlockMinutes: 120, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 21 + ), + new( + At(18), + At(23), + Daypart.Prime, + "Кино в прайм", + ShowKind.Single, + BlockMinutes: 150, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 30 + ), + new( + At(23), + At(2), + Daypart.Night, + "Ночное кино 18+", + ShowKind.Single, + MinAudience: ShowAudience.R, + BlockMinutes: 150, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 14 + ), + new( + At(2), + At(6), + Daypart.Night, + "Ночной эфир", + ShowKind.Single, + BlockMinutes: 120, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 7 + ), + ], + [] + ); + + private static GridProfile Kids() => + new( + GridProfileKind.Kids, + "Детский", + "как у детских каналов", + "Только мягкий рейтинг, короткие блоки по 3 серии, вечером «спокойной ночи», " + + "с 22:00 до утра — конец вещания вместо ночного эфира.", + [ + new( + At(6), + At(10), + Daypart.Morning, + "Утро", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 90, + UnitsPerBlock: 3 + ), + new( + At(10), + At(14), + Daypart.Day, + "День", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 90, + UnitsPerBlock: 3 + ), + new( + At(14), + At(18), + Daypart.Day, + "Дневной блок", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 90, + UnitsPerBlock: 3 + ), + new( + At(18), + At(21), + Daypart.Prime, + "Вечерний блок", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 90, + UnitsPerBlock: 3 + ), + new( + At(21), + At(22), + Daypart.Prime, + "Спокойной ночи", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 60, + UnitsPerBlock: 2 + ), + new( + At(22), + At(6), + Daypart.Night, + "Конец вещания", + SlotKind: SlotKind.SignOff, + BlockMinutes: 0 + ), + ], + [ + new( + At(6), + At(10), + Daypart.Morning, + "Утренний марафон", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 240 + ), + new( + At(10), + At(14), + Daypart.Day, + "День", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 120, + UnitsPerBlock: 4 + ), + new( + At(14), + At(18), + Daypart.Day, + "Дневной блок", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 120, + UnitsPerBlock: 4 + ), + new( + At(18), + At(21), + Daypart.Prime, + "Семейное кино", + ShowKind.Single, + ShowAudience.Pg, + BlockMinutes: 180, + UnitsPerBlock: 1, + Strategy: SlotStrategyType.RandomWithCooldown, + CooldownDays: 21 + ), + new( + At(21), + At(22), + Daypart.Prime, + "Спокойной ночи", + ShowKind.Series, + ShowAudience.Pg, + BlockMinutes: 60, + UnitsPerBlock: 2 + ), + new( + At(22), + At(6), + Daypart.Night, + "Конец вещания", + SlotKind: SlotKind.SignOff, + BlockMinutes: 0 + ), + ] + ); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/ListGridProfilesQuery.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/ListGridProfilesQuery.cs new file mode 100644 index 0000000..d933b0e --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/ListGridProfilesQuery.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Generate; + +/// Каталог профилей сетки. Читает статические данные — в БД ходить незачем. +public sealed record ListGridProfilesQuery : IQuery>>; diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/ListGridProfilesQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/ListGridProfilesQueryHandler.cs new file mode 100644 index 0000000..0e19bbf --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/ListGridProfilesQueryHandler.cs @@ -0,0 +1,23 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Generate; + +public sealed class ListGridProfilesQueryHandler + : IQueryHandler>> +{ + public Task>> Handle( + ListGridProfilesQuery query, + CancellationToken cancellationToken + ) => + Task.FromResult( + Result.Success>([ + .. GridProfiles.All.Select(p => new GridProfileDto( + p.Kind, + p.Name, + p.Reference, + p.Description + )), + ]) + ); +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/PreviewGeneratedGridQuery.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/PreviewGeneratedGridQuery.cs new file mode 100644 index 0000000..ed4391a --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/PreviewGeneratedGridQuery.cs @@ -0,0 +1,14 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Generate; + +/// +/// Что получится, если нажать генерацию прямо сейчас. Считает тот же , +/// что и сама генерация, поэтому предпросмотр — не пересказ, а ровно тот план. +/// +public sealed record PreviewGeneratedGridQuery( + Guid ChannelId, + GridProfileKind Profile, + GridGenerationMode Mode +) : IQuery>; diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/PreviewGeneratedGridQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/PreviewGeneratedGridQueryHandler.cs new file mode 100644 index 0000000..cad7f30 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/PreviewGeneratedGridQueryHandler.cs @@ -0,0 +1,25 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Generate; + +public sealed class PreviewGeneratedGridQueryHandler(GridPlanner planner) + : IQueryHandler> +{ + public async Task> Handle( + PreviewGeneratedGridQuery query, + CancellationToken cancellationToken + ) + { + var plan = await planner.BuildAsync( + query.ChannelId, + query.Profile, + query.Mode, + cancellationToken + ); + + return plan.IsSuccess + ? Result.Success(plan.Value.ToDto()) + : Result.Failure(plan.Error); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/GridCoverage.cs b/backend/src/TeleWave.Application/Programming/Templates/GridCoverage.cs new file mode 100644 index 0000000..c60e2fe --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/GridCoverage.cs @@ -0,0 +1,72 @@ +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Templates; + +/// +/// Покрытие вещательных суток слотами. Общая часть проверок сетки и автогенерации: считать дыры +/// они обязаны одинаково, иначе генератор «закроет всё», а проверки продолжат ругаться на то же +/// самое место. +/// +/// Всё меряется смещением от начала вещательных суток канала, а не часами на стене: сутки канала +/// начинаются в DayStartTime, и ночной блок с 00:00 до 06:00 относится к предыдущему дню. +/// +public static class GridCoverage +{ + public const int MinutesInDay = 24 * 60; + + /// Дни недели в порядке показа: неделя начинается с понедельника, воскресенье последнее. + public static readonly int[] Week = [1, 2, 3, 4, 5, 6, 0]; + + /// Смещение времени от начала вещательных суток, минуты. + public static int OffsetInDay(TimeOnly time, TimeOnly dayStart) + { + var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes; + return diff >= 0 ? diff : diff + MinutesInDay; + } + + /// Время в сутках канала по смещению от начала вещательных суток. + public static TimeOnly AtOffset(int offsetMinutes, TimeOnly dayStart) + { + var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay; + return new TimeOnly(minutes / 60, minutes % 60); + } + + /// Слот действует в указанный день: без дня недели — каждый день. + public static bool AppliesTo(Slot slot, int weekday) => + slot.Weekday is null || slot.Weekday == weekday; + + /// + /// Интервалы суток указанного дня, не покрытые ни одним из слотов. Границы — смещения от начала + /// вещательных суток; интервал, вылезающий за сутки, обрезается по ним. + /// + public static List<(int From, int To)> Gaps( + IEnumerable slots, + int weekday, + TimeOnly dayStart + ) + { + var intervals = slots + .Where(s => AppliesTo(s, weekday)) + .Select(s => + { + var from = OffsetInDay(s.TargetStart, dayStart); + return (From: from, To: from + s.TargetDurationMinutes); + }) + .OrderBy(i => i.From) + .ToList(); + + var gaps = new List<(int From, int To)>(); + var cursor = 0; + foreach (var interval in intervals) + { + if (interval.From > cursor) + gaps.Add((cursor, interval.From)); + cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay)); + } + + if (cursor < MinutesInDay) + gaps.Add((cursor, MinutesInDay)); + + return gaps; + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs index b56bc28..d5eb82b 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs @@ -59,6 +59,11 @@ public static class TemplateErrors "Врезке нужна группа, откуда брать ролики." ); + public static readonly Error NoGroupsToGenerate = Error.Validation( + "Templates.NoGroupsToGenerate", + "Собирать сетку не из чего: нет ни одной группы с готовым медиа." + ); + public static readonly Error RepeatSourceRequired = Error.Validation( "Templates.RepeatSourceRequired", "Слоту-повтору нужно указать, что повторять." diff --git a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs index 8b93c03..c1d0f58 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs @@ -14,8 +14,6 @@ public sealed class ValidateTemplateQueryHandler( DynamicGroupResolver dynamicResolver ) : IQueryHandler>> { - private const int MinutesInDay = 24 * 60; - public async Task>> Handle( ValidateTemplateQuery query, CancellationToken cancellationToken @@ -167,7 +165,9 @@ public sealed class ValidateTemplateQueryHandler( /// private static IEnumerable FindOverlaps(GridLayer layer, TimeOnly dayStart) { - var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList(); + var slots = layer + .Slots.OrderBy(s => GridCoverage.OffsetInDay(s.TargetStart, dayStart)) + .ToList(); // Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний, // и «телом» внешнего цикла его читает только компилятор. @@ -180,8 +180,8 @@ public sealed class ValidateTemplateQueryHandler( if (!SameDays(a, b)) continue; - var aFrom = OffsetInDay(a.TargetStart, dayStart); - var bFrom = OffsetInDay(b.TargetStart, dayStart); + var aFrom = GridCoverage.OffsetInDay(a.TargetStart, dayStart); + var bFrom = GridCoverage.OffsetInDay(b.TargetStart, dayStart); if ( aFrom < bFrom + b.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes @@ -210,30 +210,11 @@ public sealed class ValidateTemplateQueryHandler( TimeOnly dayStart ) { - foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 }) - { - var intervals = layers - .SelectMany(l => l.Slots) - .Where(s => s.Weekday is null || s.Weekday == weekday) - .Select(s => - { - var from = OffsetInDay(s.TargetStart, dayStart); - return (From: from, To: from + s.TargetDurationMinutes); - }) - .OrderBy(i => i.From) - .ToList(); + var slots = layers.SelectMany(l => l.Slots).ToList(); - var cursor = 0; - foreach (var interval in intervals) - { - if (interval.From > cursor) - yield return Gap(weekday, cursor, interval.From, dayStart); - cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay)); - } - - if (cursor < MinutesInDay) - yield return Gap(weekday, cursor, MinutesInDay, dayStart); - } + foreach (var weekday in GridCoverage.Week) + foreach (var (from, to) in GridCoverage.Gaps(slots, weekday, dayStart)) + yield return Gap(weekday, from, to, dayStart); } private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart) @@ -241,7 +222,7 @@ public sealed class ValidateTemplateQueryHandler( var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday]; // Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот. var interval = - to - from >= MinutesInDay + to - from >= GridCoverage.MinutesInDay ? "весь день" : $"{Clock(from, dayStart)}–{Clock(to, dayStart)}"; @@ -256,14 +237,8 @@ public sealed class ValidateTemplateQueryHandler( private static string Clock(int offsetMinutes, TimeOnly dayStart) { - var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay; - return $"{minutes / 60:00}:{minutes % 60:00}"; - } - - private static int OffsetInDay(TimeOnly time, TimeOnly dayStart) - { - var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes; - return diff >= 0 ? diff : diff + MinutesInDay; + var time = GridCoverage.AtOffset(offsetMinutes, dayStart); + return $"{time.Hour:00}:{time.Minute:00}"; } /// diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index f0d0dca..d4155c9 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -159,6 +159,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddHostedService(); diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingLimits.cs b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingLimits.cs new file mode 100644 index 0000000..dcbcc4f --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingLimits.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Media; + +/// +/// Читает параллельность транскода из настроек. Значение то же, по которому +/// раздаёт слоты, — иначе оценка очереди врала бы. +/// +public sealed class MediaProcessingLimits(IOptions options) : IMediaProcessingLimits +{ + public int MaxParallelTranscodes => Math.Max(1, options.Value.MaxParallelTranscodes); +} diff --git a/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs index 7663a1b..c8a6ede 100644 --- a/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs @@ -1,3 +1,5 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Media.Stats; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Media; @@ -53,7 +55,7 @@ public class MediaStatsTests } await using var db = fixture.New(); - var stats = await new GetMediaStatsQueryHandler(db).Handle( + var stats = await new GetMediaStatsQueryHandler(db, Limits(1)).Handle( new GetMediaStatsQuery(), CancellationToken.None ); @@ -63,4 +65,68 @@ public class MediaStatsTests Assert.NotNull(stats.AverageProcessingSeconds); Assert.True(stats.AverageProcessingSeconds >= 0); } + + [Fact] + public async Task Estimate_ScalesWithQueueAndParallelism() + { + var fixture = new TestDb(); + await using (var seed = fixture.New()) + { + // Одна завершённая задаёт среднее, четыре ждут своей очереди. + seed.MediaAssets.AddRange( + Ready("done.mkv"), + Pending("a.mkv"), + Pending("b.mkv"), + Pending("c.mkv"), + Pending("d.mkv") + ); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var single = await new GetMediaStatsQueryHandler(db, Limits(1)).Handle( + new GetMediaStatsQuery(), + CancellationToken.None + ); + var parallel = await new GetMediaStatsQueryHandler(db, Limits(2)).Handle( + new GetMediaStatsQuery(), + CancellationToken.None + ); + + var average = single.AverageProcessingSeconds!.Value; + Assert.Equal(4 * average, single.EstimatedRemainingSeconds!.Value, 3); + // Два транскода одновременно — вдвое меньше ждать. + Assert.Equal( + single.EstimatedRemainingSeconds!.Value / 2, + parallel.EstimatedRemainingSeconds!.Value, + 3 + ); + } + + [Fact] + public async Task Estimate_IsAbsent_WhenQueueIsEmpty() + { + var fixture = new TestDb(); + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(Ready("done.mkv")); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var stats = await new GetMediaStatsQueryHandler(db, Limits(1)).Handle( + new GetMediaStatsQuery(), + CancellationToken.None + ); + + // Ждать нечего — оценки нет вовсе, а не «0 секунд». + Assert.Null(stats.EstimatedRemainingSeconds); + } + + private static IMediaProcessingLimits Limits(int parallel) + { + var limits = Substitute.For(); + limits.MaxParallelTranscodes.Returns(parallel); + return limits; + } } diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs new file mode 100644 index 0000000..ab52ebc --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs @@ -0,0 +1,426 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.Generate; +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 GenerateGridTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + /// Шоу с готовыми сериями: без длительности группа считалась бы пустой. + private static (Show Show, List Assets) Playable( + string name, + ShowKind kind, + int episodes, + int minutes, + ShowAudience? audience = null + ) + { + var show = Show.Create(name, kind); + if (audience is { } value) + show.SetAudience(value); + + var assets = new List(); + for (var i = 0; i < episodes; i++) + { + var asset = MediaAsset.Register($"{name}-{i}.mkv", ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + TimeSpan.FromMinutes(minutes), + 6, + minutes * 10, + 1920, + 1080, + "h264", + "aac", + $"assets/{name}-{i}" + ) + ); + assets.Add(asset); + show.AddEpisode(asset.Id); + } + + return (show, assets); + } + + /// Несколько полнометражек: у одиночного шоу серия ровно одна, поэтому кино — это N шоу. + private static (List Shows, List Assets) Films( + int count, + int minutes, + ShowAudience? audience = null + ) + { + var shows = new List(); + var assets = new List(); + + for (var i = 0; i < count; i++) + { + var (show, made) = Playable($"Фильм {i}", ShowKind.Single, 1, minutes, audience); + shows.Add(show); + assets.AddRange(made); + } + + return (shows, assets); + } + + private static Group GroupOf(string name, params Guid[] showIds) + { + var group = Group.Create(name); + foreach (var id in showIds) + group.AddElement(GroupElementKind.Show, id); + return group; + } + + /// Канал с пустой сеткой: один фоновый слой без слотов. + private static async Task<(Guid ChannelId, ScheduleTemplate Template)> SeedChannelAsync( + TestDb fixture, + IEnumerable shows, + IEnumerable assets, + IEnumerable groups + ) + { + var channel = Channel.Create("Первый", "first", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + channel.SetTemplate(template.Id); + + await using var seed = fixture.New(); + seed.MediaAssets.AddRange(assets); + seed.Shows.AddRange(shows); + seed.Groups.AddRange(groups); + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + + return (channel.Id, template); + } + + private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) => + new(db, GroupServices.Dynamic(db), new GroupElementResolver(db)); + + private static async Task PlanAsync( + TestDb fixture, + Guid channelId, + GridProfileKind profile = GridProfileKind.Mixed, + GridGenerationMode mode = GridGenerationMode.FillGaps + ) + { + await using var db = fixture.New(); + var result = await Planner(db).BuildAsync(channelId, profile, mode, CancellationToken.None); + Assert.True(result.IsSuccess); + return result.Value; + } + + [Fact] + public async Task EmptyGrid_IsCoveredEntirely_AndCollapsesIntoEveryDaySlots() + { + var fixture = new TestDb(); + var series = Playable("Сериал", ShowKind.Series, 40, 25); + var films = Films(6, 100); + // Ночному блоку 18+ нужен строгий рейтинг, иначе полосу закрыть будет нечем. + var adult = Playable("Ночное", ShowKind.Series, 10, 45, ShowAudience.R); + var (channelId, _) = await SeedChannelAsync( + fixture, + [series.Show, adult.Show, .. films.Shows], + [.. series.Assets, .. adult.Assets, .. films.Assets], + [ + GroupOf("Сериалы", series.Show.Id), + GroupOf("Ночное", adult.Show.Id), + GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]), + ] + ); + + var plan = await PlanAsync(fixture, channelId); + + // Пустая сетка свободна всю неделю, и профиль обязан закрыть её целиком. + Assert.Equal(7 * GridCoverage.MinutesInDay, plan.FreeMinutes); + Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes); + // Свободно во все семь дней — значит слоты идут «каждый день», а не семью копиями. + Assert.All(plan.Slots, slot => Assert.Null(slot.Weekday)); + } + + [Fact] + public async Task PrimeGetsFeature_AndMorningGetsSeries() + { + 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); + + var prime = plan.Slots.First(s => s.Start == new TimeOnly(20, 0)); + Assert.Equal("Фильмы", prime.GroupName); + // Кино ставится по одной единице и с якорем: объявленное время начала — обещание зрителю. + Assert.Equal(SlotBlockMode.Count, prime.BlockMode); + Assert.Equal(1, prime.BlockValue); + Assert.True(prime.IsAnchor); + + var morning = plan.Slots.First(s => s.Daypart == Daypart.Morning); + Assert.Equal("Сериалы", morning.GroupName); + } + + [Fact] + public async Task ChildrenBand_SkipsAdultGroup() + { + var fixture = new TestDb(); + var adult = Playable("Взрослое", ShowKind.Series, 20, 25, ShowAudience.R); + var kids = Playable("Детское", ShowKind.Series, 20, 25, ShowAudience.G); + var (channelId, _) = await SeedChannelAsync( + fixture, + [adult.Show, kids.Show], + [.. adult.Assets, .. kids.Assets], + [GroupOf("Взрослое", adult.Show.Id), GroupOf("Детское", kids.Show.Id)] + ); + + var plan = await PlanAsync(fixture, channelId, GridProfileKind.Kids); + + // Детский профиль не пускает строгое никуда, а ночь у него — конец вещания. + Assert.DoesNotContain(plan.Slots, s => s.GroupName == "Взрослое"); + Assert.Contains(plan.Slots, s => s.SlotKind == SlotKind.SignOff); + } + + [Fact] + public async Task AdultNightBand_NeedsRatedContent() + { + var fixture = new TestDb(); + // Рейтинга нет ни у чего: ночной блок 18+ ставить не из чего, и это должно быть сказано. + var series = Playable("Сериал", ShowKind.Series, 40, 25); + var (channelId, _) = await SeedChannelAsync( + fixture, + [series.Show], + series.Assets, + [GroupOf("Сериалы", series.Show.Id)] + ); + + var plan = await PlanAsync(fixture, channelId); + + Assert.Contains(plan.Notes, note => note.Contains("Ночной блок 18+")); + Assert.True(plan.CoveredMinutes < plan.FreeMinutes); + } + + [Fact] + public async Task FillGaps_LeavesExistingSlotsAlone() + { + var fixture = new TestDb(); + var series = Playable("Сериал", ShowKind.Series, 40, 25); + var group = GroupOf("Сериалы", series.Show.Id); + var (channelId, template) = await SeedChannelAsync( + fixture, + [series.Show], + series.Assets, + [group] + ); + + // Занятая полоса: вторник с 20:00 на два часа. + await using (var db = fixture.New()) + { + var tracked = await db + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstAsync(t => t.Id == template.Id, CancellationToken.None); + var slot = tracked.Layers[0].AddSlot("Ручной", new TimeOnly(20, 0), 120, weekday: 2); + slot.UpdateContent( + new SlotContent( + "Ручной", + SlotKind.Content, + group.Id, + null, + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ) + ); + await db.SaveChangesAsync(CancellationToken.None); + } + + var plan = await PlanAsync(fixture, channelId); + + Assert.Equal(0, plan.SlotsToRemove); + Assert.Equal(7 * GridCoverage.MinutesInDay - 120, plan.FreeMinutes); + // Занятое время не застраивается: во вторник в 20:00 нового слота нет. + Assert.DoesNotContain( + plan.Slots, + s => s.Weekday == 2 && s.Start >= new TimeOnly(20, 0) && s.Start < new TimeOnly(22, 0) + ); + // Остальные дни в это время свободны, поэтому слот «каждый день» туда уже не схлопнется. + Assert.Contains(plan.Slots, s => s.Weekday is not null); + } + + [Fact] + public async Task Rebuild_CountsExistingSlotsAsRemoved_AndSeparatesWeekend() + { + var fixture = new TestDb(); + var series = Playable("Сериал", ShowKind.Series, 40, 25); + var films = Films(8, 100); + var group = GroupOf("Сериалы", series.Show.Id); + var (channelId, template) = await SeedChannelAsync( + fixture, + [series.Show, .. films.Shows], + [.. series.Assets, .. films.Assets], + [group, GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)])] + ); + + await using (var db = fixture.New()) + { + var tracked = await db + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstAsync(t => t.Id == template.Id, CancellationToken.None); + tracked.Layers[0].AddSlot("Ручной", new TimeOnly(20, 0), 120, weekday: 2); + await db.SaveChangesAsync(CancellationToken.None); + } + + var plan = await PlanAsync( + fixture, + channelId, + GridProfileKind.Mixed, + GridGenerationMode.Rebuild + ); + + Assert.Equal(1, plan.SlotsToRemove); + Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Weekend && s.Weekday == 6); + Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Main && s.Weekday is null); + } + + [Fact] + public async Task Fails_WhenNothingToPlanFrom() + { + var fixture = new TestDb(); + // Группа есть, но её шоу без готового медиа — эфира из неё не получится. + var show = Show.Create("Пусто", ShowKind.Series); + var (channelId, _) = await SeedChannelAsync( + fixture, + [show], + [], + [GroupOf("Пустая", show.Id)] + ); + + await using var db = fixture.New(); + var result = await Planner(db) + .BuildAsync( + channelId, + GridProfileKind.Mixed, + GridGenerationMode.FillGaps, + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(TemplateErrors.NoGroupsToGenerate, result.Error); + } + + [Fact] + public async Task Generate_WritesSlots_AndMarksTemplateChanged() + { + 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)]), + ] + ); + + int planned; + await using (var db = fixture.New()) + { + var result = await new GenerateGridCommandHandler( + db, + Planner(db), + new SlotWriter(db) + ).Handle( + new GenerateGridCommand( + channelId, + GridProfileKind.Mixed, + GridGenerationMode.FillGaps + ), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + planned = result.Value.Created; + Assert.Equal(0, result.Value.Removed); + 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); + + Assert.Equal(planned, stored.Layers.Sum(l => l.Slots.Count)); + Assert.Contains(stored.Layers, l => l.Name == GridPlanner.MainLayerName); + // Правка правил эфир не двигает — она только помечает шаблон изменённым. + Assert.True(stored.HasPendingChanges); + // Аварийная группа проставляется, если своей не было: иначе паузы останутся пустыми. + Assert.NotNull(stored.FallbackGroupId); + } + + [Fact] + public async Task Generate_Twice_ReusesLayer_AndAddsNothingNew() + { + 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)]), + ] + ); + + for (var run = 0; run < 2; run++) + { + await using var db = fixture.New(); + var result = await new GenerateGridCommandHandler( + db, + Planner(db), + new SlotWriter(db) + ).Handle( + new GenerateGridCommand( + channelId, + GridProfileKind.Mixed, + GridGenerationMode.FillGaps + ), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + // Второй прогон видит сетку закрытой и не создаёт ничего: дыр больше нет. + Assert.True(run == 0 ? result.Value.Created > 0 : result.Value.Created == 0); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var check = fixture.New(); + var layers = await check.ScheduleTemplates.Include(t => t.Layers).FirstAsync(); + Assert.Single(layers.Layers, l => l.Name == GridPlanner.MainLayerName); + } +} diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 7a52ada..7229e87 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -822,6 +822,33 @@ seed = hash(channelId, date, slotId, occurrenceInDay) Регулируется по силе, по умолчанию выключен: попадает точно в цель для канала «как в 96-м», но переборщить очень легко. +### 6.9. Автосборка сетки по профилю + +Собрать неделю руками — это несколько десятков слотов, и до первого эфира новый канал не доживает. +Кнопка «Собрать сетку» делает первый проход за админа; дальше сетка правится как обычно. + +**Профиль** — описание вещательных суток полосами: с какого времени по какое, какой длины блок, +сколько единиц он берёт, какой рейтинг допустим. Профили списаны с реальных каналов («как у 2×2», +«как у Paramount Comedy», «как у MTV») — такое админ проверяет по памяти, а «универсальный алгоритм +раскладки» проверить нельзя никак. Это данные (`GridProfiles`), не алгоритм: новый профиль — это +новый список полос. + +**Подбор группы под полосу** объяснимый и в том же порядке, что проверки из 5.1: рейтинг отсекает +жёстко (в детское время строгое не ставится, ночной блок 18+ без взрослого содержимого бессмыслен), +дальше баллы за тип контента, длину единицы и запас серий, минус за повторное использование в тех же +сутках. Не нашлось кандидата — полоса остаётся незакрытой, и это написано в предпросмотре, а не +обнаруживается потом списком ошибок. + +**Два режима.** «Заполнить дыры» застраивает только непокрытое время (дыры считаются тем же +`GridCoverage`, что и в 5.1) и не трогает ручные слоты; интервалы, свободные во все семь дней, +схлопываются в один слот «каждый день». «Собрать неделю с нуля» сносит все слоты шаблона и строит +будни слоем `Основная сетка`, а выходные — слоем `Выходные` поверх: слой имеет смысл, только когда +покрывает день целиком, поэтому отдельные выходные бывают лишь в этом режиме. + +**Предпросмотр обязателен**: он считается тем же `GridPlanner`, что и создание, поэтому показанное +и созданное совпадают по построению. Эфир генерация не двигает — как любая правка сетки, она только +поднимает ревизию шаблона, а хвост пересобирает кнопка применения. + --- ## 7. Порядок реализации diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index 80e27a5..f25c7b7 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -9,6 +9,11 @@ import type { CopyTemplateResultDto, CreatedIdResponse, EntryTraceDto, + GenerateGridResultDto, + GridGenerationMode, + GridPlanDto, + GridProfileDto, + GridProfileKind, JunctionAmountMode, JunctionConditions, JunctionElementKind, @@ -89,6 +94,31 @@ export function getApplyDiff(channelId: string) { return apiRequest(`/admin/channels/${channelId}/template/diff`) } +/** Каталог профилей автосборки: имя, на что похоже и для какой библиотеки годится. */ +export function listGridProfiles() { + return apiRequest('/admin/grid-profiles') +} + +/** План автосборки: что будет создано и что снесено. Считается тем же кодом, что и сама сборка. */ +export function previewGrid(channelId: string, profile: GridProfileKind, mode: GridGenerationMode) { + const query = new URLSearchParams({ profile, mode }) + return apiRequest( + `/admin/channels/${channelId}/template/grid-plan?${query.toString()}`, + ) +} + +/** Собирает сетку по профилю. План пересчитывается на сервере — с клиента едут только опции. */ +export function generateGrid( + channelId: string, + profile: GridProfileKind, + mode: GridGenerationMode, +) { + return apiRequest(`/admin/channels/${channelId}/template/generate`, { + method: 'POST', + body: { profile, mode }, + }) +} + /** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */ export function copyTemplateTo(channelId: string, targetChannelId: string) { return apiRequest( diff --git a/frontend/src/features/admin/channels/components/GenerateGridDialog.tsx b/frontend/src/features/admin/channels/components/GenerateGridDialog.tsx new file mode 100644 index 0000000..11c6d74 --- /dev/null +++ b/frontend/src/features/admin/channels/components/GenerateGridDialog.tsx @@ -0,0 +1,199 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { AlertTriangle } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { qk } from '@/shared/api/query-keys' +import type { GridGenerationMode, GridPlanSlotDto, GridProfileKind } from '@/shared/api/types' +import { cn } from '@/shared/lib/cn' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { toast } from '@/shared/ui/toast-store' +import { DurationLabel } from '../../groups/DurationLabel' +import { generateGrid, listGridProfiles, previewGrid } from '../api' + +/** + * Автосборка сетки: профиль + режим → предпросмотр → создание. План с клиента не уезжает — + * команда пересчитывает его сама, поэтому показанное и созданное не могут разойтись. + * + * Предпросмотр обязателен именно из-за режима пересборки: он сносит все слоты шаблона, и увидеть, + * что взамен, надо до нажатия, а не после. + */ +export function GenerateGridDialog({ + channelId, + onClose, + onGenerated, + onError, +}: Readonly<{ + channelId: string + onClose: () => void + onGenerated: () => void + onError: (error: unknown) => void +}>) { + const { t } = useTranslation() + const [profile, setProfile] = useState('Mixed') + const [mode, setMode] = useState('FillGaps') + + const { data: profiles } = useQuery({ + queryKey: qk.gridProfiles.all, + queryFn: listGridProfiles, + }) + + const { data: plan, isFetching } = useQuery({ + queryKey: qk.channels.gridPlan(channelId, profile, mode), + queryFn: () => previewGrid(channelId, profile, mode), + staleTime: 0, + gcTime: 0, + }) + + const generate = useMutation({ + mutationFn: () => generateGrid(channelId, profile, mode), + onSuccess: (result) => { + toast.success( + t('admin.channels.generate.done', { created: result.created, removed: result.removed }), + ) + onGenerated() + onClose() + }, + onError, + }) + + const covered = + plan && plan.freeMinutes > 0 ? Math.round((plan.coveredMinutes / plan.freeMinutes) * 100) : 0 + + return ( + !next && onClose()}> + + + {t('admin.channels.generate.title')} + {t('admin.channels.generate.hint')} + + +
+ {/* Профиль — то, ради чего окно и открыли: ритм суток задаёт всё остальное. */} +
+ {(profiles ?? []).map((item) => ( + + ))} +
+ +
+ {(['FillGaps', 'Rebuild'] as const).map((value) => ( + + ))} + + {t(`admin.channels.generate.modeHints.${mode}`)} + +
+ + {isFetching &&

{t('common.loading')}

} + + {plan && !isFetching && ( + <> +

+ {t('admin.channels.generate.summary', { + slots: plan.slots.length, + percent: covered, + })} +

+ + {plan.slotsToRemove > 0 && ( +

+ + {t('admin.channels.generate.willRemove', { count: plan.slotsToRemove })} +

+ )} + + {plan.fallbackGroupName && ( +

+ {t('admin.channels.generate.fallback', { name: plan.fallbackGroupName })} +

+ )} + + {plan.notes.length > 0 && ( +
    + {plan.notes.map((note) => ( +
  • {note}
  • + ))} +
+ )} + + {plan.slots.length === 0 ? ( +

{t('admin.channels.generate.nothing')}

+ ) : ( +
    + {plan.slots.map((slot, index) => ( + + ))} +
+ )} + + )} +
+ + + + + +
+
+ ) +} + +function PlanRow({ slot }: Readonly<{ slot: GridPlanSlotDto }>) { + const { t } = useTranslation() + + return ( +
  • + + {slot.weekday === null + ? t('admin.channels.everyDay') + : t(`admin.channels.weekdays.${slot.weekday}`)} + + {slot.start} + + + + {slot.title} + {slot.block} +
  • + ) +} diff --git a/frontend/src/features/admin/channels/components/GridTab.tsx b/frontend/src/features/admin/channels/components/GridTab.tsx index a1ce7bb..7a28bdd 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 } from 'lucide-react' +import { Plus, Wand2 } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { HttpError } from '@/shared/api/client' @@ -21,6 +21,7 @@ import { updateSlot, } from '../api' import { toTime } from '../lib/format' +import { GenerateGridDialog } from './GenerateGridDialog' import { LayerApplicabilityDialog } from './LayerApplicabilityDialog' import { LayerList, ScheduleGrid } from './ScheduleGrid' import { SlotInspector, type SlotDraft } from './SlotInspector' @@ -50,6 +51,7 @@ export function GridTab({ const [activeLayerId, setActiveLayerId] = useState(null) const [viewDate, setViewDate] = useState('') const [applicabilityLayer, setApplicabilityLayer] = useState(null) + const [generating, setGenerating] = useState(false) // День, который копируем, и отмеченные дни-приёмники. const [copySource, setCopySource] = useState(null) const [copyTargets, setCopyTargets] = useState([]) @@ -302,6 +304,10 @@ export function GridTab({ {t('admin.channels.newSlot')} + {t('admin.channels.showForDate')} + {generating && ( + setGenerating(false)} + onGenerated={onChanged} + onError={onError} + /> + )} + {applicabilityLayer && ( + {/* Оценка появляется только когда есть что ждать: «Осталось: —» бесполезно. */} + {stats.estimatedRemainingSeconds !== null && ( + + {t('admin.media.stats.etaShort')}:{' '} + + + + + )} )} @@ -267,6 +276,21 @@ export function MediaPanel() { ) } +/** + * «Осталось: ~2 ч 40 мин». Тильда и округление до минут намеренные: это оценка по среднему времени, + * и точная до секунды подпись читалась бы как обещание. + */ +function EtaValue({ seconds }: Readonly<{ seconds: number }>) { + const { t } = useTranslation() + const parts = splitEta(seconds) + if (!parts) return <>— + if (parts.hours === 0 && parts.minutes === 0) return <>{t('admin.media.stats.etaSoon')} + + const hours = parts.hours > 0 ? `${parts.hours} ${t('admin.media.stats.hoursShort')}` : '' + const minutes = parts.minutes > 0 ? `${parts.minutes} ${t('admin.media.stats.minutesShort')}` : '' + return <>~{[hours, minutes].filter(Boolean).join(' ')} +} + function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) { const { t } = useTranslation() return ( diff --git a/frontend/src/features/admin/media/format.ts b/frontend/src/features/admin/media/format.ts index 290b4fa..f65f035 100644 --- a/frontend/src/features/admin/media/format.ts +++ b/frontend/src/features/admin/media/format.ts @@ -8,3 +8,14 @@ export function formatDuration(seconds: number | null): string { const pad = (n: number) => String(n).padStart(2, '0') return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}` } + +/** + * Оценка «сколько ещё ждать» — часами и минутами, без секунд. Точность здесь была бы мнимой: оценка + * строится на среднем времени обработки, и «13:27:42» обещало бы то, чего никто не гарантирует. + * Меньше минуты — не разбиваем на части, вернём нули: подпись всё равно скажет «меньше минуты». + */ +export function splitEta(seconds: number | null): { hours: number; minutes: number } | null { + if (seconds == null) return null + const total = Math.max(0, Math.round(seconds)) + return { hours: Math.floor(total / 3600), minutes: Math.round((total % 3600) / 60) } +} diff --git a/frontend/src/shared/api/query-keys.ts b/frontend/src/shared/api/query-keys.ts index 644bbfa..6460015 100644 --- a/frontend/src/shared/api/query-keys.ts +++ b/frontend/src/shared/api/query-keys.ts @@ -24,6 +24,13 @@ export const qk = { issues: (id: string) => ['admin', 'channels', id, 'issues'] as const, diff: (id: string) => ['admin', 'channels', id, 'diff'] as const, preview: (id: string, days: number) => ['admin', 'channels', id, 'preview', days] as const, + /** План автосборки — свой на каждую комбинацию профиля и режима. */ + gridPlan: (id: string, profile: string, mode: string) => + ['admin', 'channels', id, 'grid-plan', profile, mode] as const, + }, + + gridProfiles: { + all: ['admin', 'grid-profiles'] as const, }, entries: { diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index e3ba05f..d82437e 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -109,6 +109,8 @@ export type MediaStatsDto = { queued: number processing: number averageProcessingSeconds: number | null + /** Оценка времени до конца очереди, сек; null — очередь пуста либо среднее ещё не набралось. */ + estimatedRemainingSeconds: number | null } // ── Библиотека (жанры) ───────────────────────────────────────────────────── @@ -692,6 +694,50 @@ export type CopyTemplateResultDto = { droppedBumperRefs: number } +// ── Автосборка сетки ────────────────────────────────────────────────────── + +/** Архетип сетки: с какого типа канала списан ритм суток. */ +export type GridProfileKind = 'Mixed' | 'Animation' | 'Sitcom' | 'Music' | 'Movies' | 'Kids' + +/** Что делать с тем, что в сетке уже есть. */ +export type GridGenerationMode = 'FillGaps' | 'Rebuild' + +export type GridProfileDto = { + kind: GridProfileKind + name: string + /** На что похоже — «как у 2×2». */ + reference: string + description: string +} + +/** Строка предпросмотра: будущий слот до того, как он создан. */ +export type GridPlanSlotDto = { + layer: 'Main' | 'Weekend' + weekday: number | null + start: string + durationMinutes: number + title: string + daypart: Daypart + slotKind: SlotKind + groupName: string | null + /** Человеческое описание блока: «4 подряд», «до конца слота». */ + block: string +} + +export type GridPlanDto = { + profile: GridProfileKind + profileName: string + slots: GridPlanSlotDto[] + notes: string[] + /** Больше нуля только при пересборке — столько слотов будет снесено. */ + slotsToRemove: number + fallbackGroupName: string | null + freeMinutes: number + coveredMinutes: number +} + +export type GenerateGridResultDto = { created: number; removed: number } + /** Проверки сетки по правилам, до генерации (см. 5.1). */ type TemplateIssueKind = | 'GroupEmpty' diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index ce99860..1c6a7ac 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -318,6 +318,11 @@ export const en = { processingShort: 'Processing', average: 'Average processing time (recent)', averageShort: 'Avg time', + eta: 'Estimated time until the queue drains: work left at the average rate, divided by the number of parallel transcodes', + etaShort: 'Left', + etaSoon: 'under a minute', + hoursShort: 'h', + minutesShort: 'min', }, }, gallery: { @@ -423,6 +428,27 @@ export const en = { newLayerName: 'New layer', addSlotHere: 'Add slot', newSlot: 'New slot', + /** Grid auto-build by profile: plan preview and creation. */ + generate: { + action: 'Build grid', + title: 'Grid auto-build', + hint: 'A profile sets the rhythm of the day: block length, what airs in prime time and what fills the night. Groups are picked from the existing ones by rating, content type and how many units they hold.', + modes: { + FillGaps: 'Fill gaps', + Rebuild: 'Rebuild the week', + }, + modeHints: { + FillGaps: 'Existing slots are left alone — only uncovered time is filled.', + Rebuild: + 'All template slots are removed; the weekend is built as a separate layer on top.', + }, + summary: 'Slots to create: {{slots}} — that is {{percent}}% of the free week.', + willRemove: 'Slots to remove: {{count}}.', + fallback: 'The fallback group will be "{{name}}" — it covers pauses between slots.', + nothing: 'Nothing to create: there is no free time in the grid.', + create: 'Build', + done: 'Created {{created}} slots, removed {{removed}}.', + }, editSlot: 'Slot', slotTitle: 'Block title', slotStart: 'Start', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 82254e3..505ee94 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -319,6 +319,11 @@ export const ru = { processingShort: 'В обработке', average: 'Среднее время обработки (по недавним)', averageShort: 'Ср. время', + eta: 'Примерное время до конца очереди: оставшаяся работа по среднему, делённая на число параллельных транскодов', + etaShort: 'Осталось', + etaSoon: 'меньше минуты', + hoursShort: 'ч', + minutesShort: 'мин', }, }, gallery: { @@ -424,6 +429,26 @@ export const ru = { newLayerName: 'Новый слой', addSlotHere: 'Добавить слот', newSlot: 'Новый слот', + /** Автосборка сетки по профилю: предпросмотр плана и его создание. */ + generate: { + action: 'Собрать сетку', + title: 'Автосборка сетки', + hint: 'Профиль задаёт ритм суток: длину блоков, что стоит в прайме и чем закрыта ночь. Группы под каждую полосу подбираются из существующих — по рейтингу, типу контента и запасу серий.', + modes: { + FillGaps: 'Заполнить дыры', + Rebuild: 'Собрать неделю с нуля', + }, + modeHints: { + FillGaps: 'Существующие слоты не трогаются — застраивается только незакрытое время.', + Rebuild: 'Все слоты шаблона сносятся; выходные строятся отдельным слоем поверх будних.', + }, + summary: 'Будет создано слотов: {{slots}} — это {{percent}}% свободного времени недели.', + willRemove: 'Будет снесено слотов: {{count}}.', + fallback: 'Аварийной группой станет «{{name}}» — ею закрываются паузы между слотами.', + nothing: 'Создавать нечего: свободного времени в сетке нет.', + create: 'Собрать', + done: 'Создано слотов: {{created}}, снесено: {{removed}}.', + }, editSlot: 'Слот', slotTitle: 'Название блока', slotStart: 'Начало',