Add grid auto-build functionality with profile management and preview capabilities
Implemented new API endpoints and frontend components for grid auto-building based on predefined profiles. Added functionality to list grid profiles, preview generated grids, and generate grids with specified parameters. Enhanced the TemplateEndpoints and related services to support these features, improving the user experience for channel scheduling. Localization updates were made to accommodate new features in both English and Russian.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Пропускная способность обработчика медиа. Нужна, чтобы по очереди и среднему времени посчитать,
|
||||
/// когда она разгребётся: при двух параллельных транскодах ждать вдвое меньше, чем при одном.
|
||||
///
|
||||
/// Порт, а не чтение настроек напрямую: настройки ffmpeg живут в Infrastructure, и прикладной слой
|
||||
/// про их устройство знать не должен.
|
||||
/// </summary>
|
||||
public interface IMediaProcessingLimits
|
||||
{
|
||||
/// <summary>Сколько файлов обрабатывается одновременно.</summary>
|
||||
int MaxParallelTranscodes { get; }
|
||||
}
|
||||
@@ -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<GroupSuggestionBuilder>();
|
||||
services.AddScoped<GroupMembershipCleaner>();
|
||||
services.AddScoped<SlotWriter>();
|
||||
services.AddScoped<GridPlanner>();
|
||||
services.AddScoped<GroupExpander>();
|
||||
services.AddScoped<BumperResolver>();
|
||||
services.AddScoped<PostCheckRunner>();
|
||||
|
||||
@@ -8,4 +8,13 @@ public sealed record GetMediaStatsQuery : IQuery<MediaStatsDto>;
|
||||
/// <param name="Queued">Ассетов в статусе Pending (ждут обработки) сейчас.</param>
|
||||
/// <param name="Processing">Ассетов в статусе Processing (обрабатываются) сейчас.</param>
|
||||
/// <param name="AverageProcessingSeconds">Среднее время обработки по недавним завершённым (Ready), сек; null — если нет данных.</param>
|
||||
public sealed record MediaStatsDto(int Queued, int Processing, double? AverageProcessingSeconds);
|
||||
/// <param name="EstimatedRemainingSeconds">
|
||||
/// Оценка времени до конца очереди, сек: работа, оставшаяся по всем ждущим и уже начатым ассетам,
|
||||
/// делённая на число параллельных транскодов. null — очередь пуста либо среднее ещё не набралось.
|
||||
/// </param>
|
||||
public sealed record MediaStatsDto(
|
||||
int Queued,
|
||||
int Processing,
|
||||
double? AverageProcessingSeconds,
|
||||
double? EstimatedRemainingSeconds
|
||||
);
|
||||
|
||||
@@ -5,8 +5,10 @@ using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Media.Stats;
|
||||
|
||||
public sealed class GetMediaStatsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetMediaStatsQuery, MediaStatsDto>
|
||||
public sealed class GetMediaStatsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IMediaProcessingLimits limits
|
||||
) : IQueryHandler<GetMediaStatsQuery, MediaStatsDto>
|
||||
{
|
||||
/// <summary>По скольким последним завершённым ассетам усредняем время обработки.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сколько ещё крутиться очереди. Считается как суммарная оставшаяся работа, делённая на число
|
||||
/// параллельных транскодов: у ждущих в запасе целое среднее, у уже начатых — среднее минус то,
|
||||
/// что они уже отработали.
|
||||
///
|
||||
/// Оценка, а не обещание: длинный фильм после десятка роликов сдвинет её вверх. Но и без неё
|
||||
/// «492 в очереди» не говорит администратору ничего — это может быть и час, и трое суток.
|
||||
/// </summary>
|
||||
private async Task<double?> EstimateRemainingAsync(
|
||||
IQueryable<MediaAsset> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
/// <summary>
|
||||
/// Собрать сетку по профилю. План не передаётся с клиента: команда пересчитывает его сама на свежих
|
||||
/// данных — за время, пока админ смотрел предпросмотр, состав групп мог измениться.
|
||||
/// </summary>
|
||||
public sealed record GenerateGridCommand(
|
||||
Guid ChannelId,
|
||||
GridProfileKind Profile,
|
||||
GridGenerationMode Mode
|
||||
) : ICommand<Result<GenerateGridResultDto>>;
|
||||
|
||||
public sealed class GenerateGridCommandValidator : AbstractValidator<GenerateGridCommand>
|
||||
{
|
||||
public GenerateGridCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Profile).IsInEnum();
|
||||
RuleFor(x => x.Mode).IsInEnum();
|
||||
}
|
||||
}
|
||||
+106
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт слоты по плану. Слои заводятся по именам профиля и переиспользуются: повторная генерация
|
||||
/// не должна плодить «Основная сетка (2)».
|
||||
///
|
||||
/// Эфир при этом не двигается — как и любая правка сетки, генерация только помечает шаблон
|
||||
/// изменённым, а хвост пересобирает отдельная кнопка применения.
|
||||
/// </summary>
|
||||
public sealed class GenerateGridCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
GridPlanner planner,
|
||||
SlotWriter writer
|
||||
) : ICommandHandler<GenerateGridCommand, Result<GenerateGridResultDto>>
|
||||
{
|
||||
/// <summary>Приоритеты слоёв генератора: выходные обязаны перекрывать будни.</summary>
|
||||
private const int MainPriority = 10;
|
||||
private const int WeekendPriority = 20;
|
||||
|
||||
public async Task<Result<GenerateGridResultDto>> Handle(
|
||||
GenerateGridCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var planned = await planner.BuildAsync(
|
||||
command.ChannelId,
|
||||
command.Profile,
|
||||
command.Mode,
|
||||
cancellationToken
|
||||
);
|
||||
if (!planned.IsSuccess)
|
||||
return Result.Failure<GenerateGridResultDto>(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<GenerateGridResultDto>(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<GenerateGridResultDto>(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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
/// <summary>Что делать с тем, что в сетке уже есть.</summary>
|
||||
public enum GridGenerationMode
|
||||
{
|
||||
/// <summary>Только незакрытые интервалы; ручные слоты не трогаются.</summary>
|
||||
FillGaps = 0,
|
||||
|
||||
/// <summary>Снести все слоты шаблона и построить неделю заново.</summary>
|
||||
Rebuild = 1,
|
||||
}
|
||||
|
||||
/// <summary>Куда ложится слот. Имена слоёв — в <see cref="GridPlanner"/>.</summary>
|
||||
public enum GridPlanLayer
|
||||
{
|
||||
Main = 0,
|
||||
Weekend = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Будущий слот: то же, что <see cref="SlotInput"/>, но без слоя и идентификаторов. Сравнивается
|
||||
/// по значению — на этом держится схлопывание одинаковых дней в один слот «каждый день».
|
||||
/// </summary>
|
||||
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,
|
||||
/// <summary>Жёсткий старт. Ставится в начале прайма: кино должно начинаться в объявленное время.</summary>
|
||||
bool IsAnchor
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Результат разбора: что будет создано, что снесено и о чём стоит знать заранее. Считается и для
|
||||
/// предпросмотра, и для самой генерации — одним и тем же кодом, поэтому показанное совпадает
|
||||
/// с созданным.
|
||||
/// </summary>
|
||||
/// <param name="FreeMinutes">Сколько минут недели генератор считал свободными.</param>
|
||||
/// <param name="CoveredMinutes">Сколько из них закрыто слотами плана.</param>
|
||||
public sealed record GridPlan(
|
||||
GridProfile Profile,
|
||||
GridGenerationMode Mode,
|
||||
IReadOnlyList<PlannedSlot> Slots,
|
||||
IReadOnlyList<string> Notes,
|
||||
int SlotsToRemove,
|
||||
Guid? FallbackGroupId,
|
||||
string? FallbackGroupName,
|
||||
int FreeMinutes,
|
||||
int CoveredMinutes
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
/// <summary>Профиль в списке выбора: имя, на что похоже и для какой библиотеки годится.</summary>
|
||||
public sealed record GridProfileDto(
|
||||
GridProfileKind Kind,
|
||||
string Name,
|
||||
string Reference,
|
||||
string Description
|
||||
);
|
||||
|
||||
/// <summary>Строка предпросмотра — будущий слот так, как его покажут админу.</summary>
|
||||
/// <param name="Block">Человеческое описание блока: «4 подряд», «до конца слота».</param>
|
||||
public sealed record GridPlanSlotDto(
|
||||
GridPlanLayer Layer,
|
||||
int? Weekday,
|
||||
string Start,
|
||||
int DurationMinutes,
|
||||
string Title,
|
||||
Daypart Daypart,
|
||||
SlotKind SlotKind,
|
||||
string? GroupName,
|
||||
string Block
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// План генерации целиком. <see cref="SlotsToRemove"/> больше нуля только в режиме пересборки —
|
||||
/// это то, что будет снесено, и показать это до нажатия обязательно.
|
||||
/// </summary>
|
||||
public sealed record GridPlanDto(
|
||||
GridProfileKind Profile,
|
||||
string ProfileName,
|
||||
IReadOnlyList<GridPlanSlotDto> Slots,
|
||||
IReadOnlyList<string> Notes,
|
||||
int SlotsToRemove,
|
||||
string? FallbackGroupName,
|
||||
int FreeMinutes,
|
||||
int CoveredMinutes
|
||||
);
|
||||
|
||||
public sealed record GenerateGridResultDto(int Created, int Removed);
|
||||
|
||||
/// <summary>Перевод плана в то, что уезжает на клиент. Общий для предпросмотра и результата.</summary>
|
||||
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} мин",
|
||||
_ => "до конца слота",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Раскладывает свободное время канала по полосам выбранного профиля и подбирает под каждую полосу
|
||||
/// группу. Ничего не пишет: результат — план, который показывают в предпросмотре и по которому
|
||||
/// потом создают слоты. Один и тот же расчёт для обеих операций — иначе показанное и созданное
|
||||
/// разошлись бы.
|
||||
///
|
||||
/// Подбор намеренно объяснимый: жёстко отсекается неподходящее по рейтингу, дальше баллы за тип
|
||||
/// контента, длину единицы и запас серий, минус за повторное использование в тех же сутках. Админ
|
||||
/// должен понимать, почему в прайм встала именно эта группа.
|
||||
/// </summary>
|
||||
public sealed class GridPlanner(
|
||||
IAppDbContext dbContext,
|
||||
DynamicGroupResolver dynamicResolver,
|
||||
GroupElementResolver elementResolver
|
||||
)
|
||||
{
|
||||
public const string MainLayerName = "Основная сетка";
|
||||
public const string WeekendLayerName = "Выходные";
|
||||
|
||||
/// <summary>Слот короче этого смысла не имеет — остаток отдаётся соседнему слоту.</summary>
|
||||
private const int MinSlotMinutes = 15;
|
||||
|
||||
/// <summary>Длины слотов округляются до пяти минут: сетка должна читаться, а не считаться.</summary>
|
||||
private const int SlotStepMinutes = 5;
|
||||
|
||||
/// <summary>Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один.</summary>
|
||||
private const int FeatureLengthMinutes = 60;
|
||||
|
||||
public async Task<Result<GridPlan>> 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<GridPlan>(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<GridPlan>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var groups = await LoadGroupsAsync(cancellationToken);
|
||||
if (groups.Count == 0)
|
||||
return Result.Failure<GridPlan>(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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Неделя с нуля: будни ложатся слотами «каждый день» в основной слой, выходные — отдельным
|
||||
/// слоем поверх. Слой выходных перекрывает будний целиком, поэтому расхождение по субботе
|
||||
/// и воскресенью получается без дублирования всей недели по дням.
|
||||
/// </summary>
|
||||
private static List<PlannedSlot> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Заполнение дыр: считается по каждому дню отдельно, поэтому слоты получают явный день недели.
|
||||
/// Интервалы, свободные во все семь дней, схлопываются в один слот «каждый день» — иначе первая
|
||||
/// же генерация на пустом канале выдала бы семь одинаковых столбцов.
|
||||
///
|
||||
/// Полосы берутся будние на все семь дней: отдельные выходные строятся слоем поверх буднего,
|
||||
/// а слой имеет смысл только когда он покрывает день целиком. Заплатка в дыре этого не даёт,
|
||||
/// поэтому выходные — дело режима пересборки.
|
||||
/// </summary>
|
||||
private static List<PlannedSlot> FillExistingGaps(PlanRun run, IReadOnlyList<Slot> 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<PlannedSlot> Collapse(List<(int Weekday, PlannedSlot Slot)> entries)
|
||||
{
|
||||
var result = new List<PlannedSlot>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Раскладывает свободные интервалы одних суток по полосам профиля.</summary>
|
||||
private static List<PlannedSlot> FillDay(
|
||||
IReadOnlyList<GridBand> bands,
|
||||
IReadOnlyList<(int From, int To)> free,
|
||||
int? weekday,
|
||||
GridPlanLayer layer,
|
||||
PlanRun run
|
||||
)
|
||||
{
|
||||
// Счётчик использований живёт внутри суток: группа, занятая утром, уступает вечером другой,
|
||||
// но на следующий день снова доступна — иначе раскладка зависела бы от порядка дней.
|
||||
var used = new Dictionary<Guid, int>();
|
||||
var slots = new List<PlannedSlot>();
|
||||
|
||||
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))];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полоса в смещениях от начала вещательных суток. Начало суток канала произвольно, поэтому
|
||||
/// полоса может перевалить через их конец — тогда она разрезается на два куска.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Режет кусок полосы на слоты целевой длины и подбирает каждому наполнение.</summary>
|
||||
private static IEnumerable<PlannedSlot> FillPiece(
|
||||
GridBand band,
|
||||
int from,
|
||||
int to,
|
||||
int? weekday,
|
||||
GridPlanLayer layer,
|
||||
PlanRun run,
|
||||
Dictionary<Guid, int> 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<Guid, int> 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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной
|
||||
/// взрослый блок без взрослого содержимого не имеет смысла. Остальное — баллы.
|
||||
/// </summary>
|
||||
private static GroupCandidate? Pick(GridBand band, PlanRun run, Dictionary<Guid, int> 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<Guid, int> 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<List<GroupCandidate>> LoadGroupsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var candidates = new List<GroupCandidate>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Группа глазами раскладки: чем её можно закрыть и насколько её хватит.</summary>
|
||||
private sealed record GroupCandidate(
|
||||
Guid Id,
|
||||
string Name,
|
||||
int UnitCount,
|
||||
ShowAudience? Strictest,
|
||||
ShowKind DominantKind,
|
||||
double AverageUnitMinutes
|
||||
);
|
||||
|
||||
/// <summary>Общее состояние одного разбора: профиль, кандидаты и накопленные замечания.</summary>
|
||||
private sealed class PlanRun(
|
||||
GridProfile profile,
|
||||
IReadOnlyList<GroupCandidate> groups,
|
||||
TimeOnly dayStart
|
||||
)
|
||||
{
|
||||
private readonly List<string> _notes = [];
|
||||
private readonly HashSet<string> _seen = [];
|
||||
|
||||
public GridProfile Profile { get; } = profile;
|
||||
public IReadOnlyList<GroupCandidate> Groups { get; } = groups;
|
||||
public TimeOnly DayStart { get; } = dayStart;
|
||||
public IReadOnlyList<string> Notes => _notes;
|
||||
|
||||
public void Note(string text)
|
||||
{
|
||||
if (_seen.Add(text))
|
||||
_notes.Add(text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Предупреждение о нехватке состава — то же, о чём потом скажут проверки сетки. Показать
|
||||
/// это до создания слотов честнее, чем дать админу нажать кнопку и получить список ошибок.
|
||||
/// </summary>
|
||||
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}» — повторы пойдут чаще, чем раз в неделю."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
/// <summary>Архетип сетки. Значения пишутся в запросы клиентом, поэтому переименованию не подлежат.</summary>
|
||||
public enum GridProfileKind
|
||||
{
|
||||
Mixed = 0,
|
||||
Animation = 1,
|
||||
Sitcom = 2,
|
||||
Music = 3,
|
||||
Movies = 4,
|
||||
Kids = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полоса вещательных суток — «утренний блок», «прайм», «ночь». Описывает, чем полосу закрывать и
|
||||
/// каким куском: длина слота, сколько единиц он берёт за выход и по какой стратегии выбирает.
|
||||
///
|
||||
/// Времена — часы канала (не UTC и не смещения): профиль пишется так, как его читает человек,
|
||||
/// а перевод в смещения от начала вещательных суток делает планировщик.
|
||||
/// </summary>
|
||||
/// <param name="BlockMinutes">Целевая длина одного слота внутри полосы; 0 — полоса одним слотом.</param>
|
||||
/// <param name="UnitsPerBlock">Сколько единиц берёт слот за выход; 0 — сколько влезет в бюджет.</param>
|
||||
/// <param name="MinAudience">Нижняя граница рейтинга: так задаются ночные взрослые блоки.</param>
|
||||
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
|
||||
)
|
||||
{
|
||||
/// <summary>Длина полосы в минутах; полоса через полночь считается вперёд, а не назад.</summary>
|
||||
public int LengthMinutes
|
||||
{
|
||||
get
|
||||
{
|
||||
var length = (int)(To.ToTimeSpan() - From.ToTimeSpan()).TotalMinutes;
|
||||
return length > 0 ? length : length + GridCoverage.MinutesInDay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Готовый профиль сетки: как выглядят вещательные сутки у канала такого типа. Профили списаны
|
||||
/// с реальных каналов намеренно — «как у 2×2» админ проверяет по памяти, а «универсальный
|
||||
/// алгоритм раскладки» проверить нельзя никак.
|
||||
///
|
||||
/// <see cref="Weekend"/> пуст — выходные идут по будним полосам.
|
||||
/// </summary>
|
||||
public sealed record GridProfile(
|
||||
GridProfileKind Kind,
|
||||
string Name,
|
||||
string Reference,
|
||||
string Description,
|
||||
IReadOnlyList<GridBand> Weekdays,
|
||||
IReadOnlyList<GridBand> Weekend
|
||||
);
|
||||
@@ -0,0 +1,584 @@
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
/// <summary>
|
||||
/// Каталог профилей сетки. Списаны с реальных каналов: у каждого свой ритм — длина блока, что
|
||||
/// стоит в прайме, чем закрыта ночь. Это данные, а не алгоритм: чтобы добавить профиль, достаточно
|
||||
/// описать полосы суток.
|
||||
///
|
||||
/// Полосы каждого профиля обязаны покрывать сутки целиком (06:00 → 06:00 следующих) — генератор
|
||||
/// раскладывает по ним свободное время, и незакрытая полосой минута останется дырой в сетке.
|
||||
/// </summary>
|
||||
public static class GridProfiles
|
||||
{
|
||||
public static IReadOnlyList<GridProfile> 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);
|
||||
|
||||
/// <summary>Ночной взрослый блок — общая концовка суток почти у всех профилей.</summary>
|
||||
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
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
/// <summary>Каталог профилей сетки. Читает статические данные — в БД ходить незачем.</summary>
|
||||
public sealed record ListGridProfilesQuery : IQuery<Result<IReadOnlyList<GridProfileDto>>>;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
public sealed class ListGridProfilesQueryHandler
|
||||
: IQueryHandler<ListGridProfilesQuery, Result<IReadOnlyList<GridProfileDto>>>
|
||||
{
|
||||
public Task<Result<IReadOnlyList<GridProfileDto>>> Handle(
|
||||
ListGridProfilesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
Task.FromResult(
|
||||
Result.Success<IReadOnlyList<GridProfileDto>>([
|
||||
.. GridProfiles.All.Select(p => new GridProfileDto(
|
||||
p.Kind,
|
||||
p.Name,
|
||||
p.Reference,
|
||||
p.Description
|
||||
)),
|
||||
])
|
||||
);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
/// <summary>
|
||||
/// Что получится, если нажать генерацию прямо сейчас. Считает тот же <see cref="GridPlanner"/>,
|
||||
/// что и сама генерация, поэтому предпросмотр — не пересказ, а ровно тот план.
|
||||
/// </summary>
|
||||
public sealed record PreviewGeneratedGridQuery(
|
||||
Guid ChannelId,
|
||||
GridProfileKind Profile,
|
||||
GridGenerationMode Mode
|
||||
) : IQuery<Result<GridPlanDto>>;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
|
||||
public sealed class PreviewGeneratedGridQueryHandler(GridPlanner planner)
|
||||
: IQueryHandler<PreviewGeneratedGridQuery, Result<GridPlanDto>>
|
||||
{
|
||||
public async Task<Result<GridPlanDto>> 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<GridPlanDto>(plan.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Покрытие вещательных суток слотами. Общая часть проверок сетки и автогенерации: считать дыры
|
||||
/// они обязаны одинаково, иначе генератор «закроет всё», а проверки продолжат ругаться на то же
|
||||
/// самое место.
|
||||
///
|
||||
/// Всё меряется смещением от начала вещательных суток канала, а не часами на стене: сутки канала
|
||||
/// начинаются в <c>DayStartTime</c>, и ночной блок с 00:00 до 06:00 относится к предыдущему дню.
|
||||
/// </summary>
|
||||
public static class GridCoverage
|
||||
{
|
||||
public const int MinutesInDay = 24 * 60;
|
||||
|
||||
/// <summary>Дни недели в порядке показа: неделя начинается с понедельника, воскресенье последнее.</summary>
|
||||
public static readonly int[] Week = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
/// <summary>Смещение времени от начала вещательных суток, минуты.</summary>
|
||||
public static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
|
||||
{
|
||||
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
|
||||
return diff >= 0 ? diff : diff + MinutesInDay;
|
||||
}
|
||||
|
||||
/// <summary>Время в сутках канала по смещению от начала вещательных суток.</summary>
|
||||
public static TimeOnly AtOffset(int offsetMinutes, TimeOnly dayStart)
|
||||
{
|
||||
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
|
||||
return new TimeOnly(minutes / 60, minutes % 60);
|
||||
}
|
||||
|
||||
/// <summary>Слот действует в указанный день: без дня недели — каждый день.</summary>
|
||||
public static bool AppliesTo(Slot slot, int weekday) =>
|
||||
slot.Weekday is null || slot.Weekday == weekday;
|
||||
|
||||
/// <summary>
|
||||
/// Интервалы суток указанного дня, не покрытые ни одним из слотов. Границы — смещения от начала
|
||||
/// вещательных суток; интервал, вылезающий за сутки, обрезается по ним.
|
||||
/// </summary>
|
||||
public static List<(int From, int To)> Gaps(
|
||||
IEnumerable<Slot> 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
"Слоту-повтору нужно указать, что повторять."
|
||||
|
||||
+12
-37
@@ -14,8 +14,6 @@ public sealed class ValidateTemplateQueryHandler(
|
||||
DynamicGroupResolver dynamicResolver
|
||||
) : IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
||||
{
|
||||
private const int MinutesInDay = 24 * 60;
|
||||
|
||||
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
|
||||
ValidateTemplateQuery query,
|
||||
CancellationToken cancellationToken
|
||||
@@ -167,7 +165,9 @@ public sealed class ValidateTemplateQueryHandler(
|
||||
/// </summary>
|
||||
private static IEnumerable<TemplateIssueDto> 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}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user