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:
@@ -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<CopyTemplateResultDto>();
|
||||
|
||||
// Автосборка сетки: каталог профилей, план по выбранному и его создание. План считается
|
||||
// одним и тем же кодом, поэтому предпросмотр показывает ровно то, что создаст кнопка.
|
||||
admin
|
||||
.MapGet("/grid-profiles", ListGridProfiles)
|
||||
.Produces<IReadOnlyList<GridProfileDto>>();
|
||||
admin
|
||||
.MapGet("/channels/{channelId:guid}/template/grid-plan", PreviewGrid)
|
||||
.Produces<GridPlanDto>();
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid)
|
||||
.Produces<GenerateGridResultDto>();
|
||||
|
||||
admin
|
||||
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
@@ -158,6 +171,44 @@ public static class TemplateEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListGridProfiles(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListGridProfilesQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> 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<IResult> 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<IResult> 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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -159,6 +159,7 @@ public static class DependencyInjection
|
||||
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
|
||||
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
||||
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
|
||||
services.AddSingleton<IMediaProcessingLimits, MediaProcessingLimits>();
|
||||
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
|
||||
|
||||
services.AddHostedService<MediaProcessingBackgroundService>();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Читает параллельность транскода из настроек. Значение то же, по которому
|
||||
/// <see cref="MediaProcessingBackgroundService"/> раздаёт слоты, — иначе оценка очереди врала бы.
|
||||
/// </summary>
|
||||
public sealed class MediaProcessingLimits(IOptions<MediaOptions> options) : IMediaProcessingLimits
|
||||
{
|
||||
public int MaxParallelTranscodes => Math.Max(1, options.Value.MaxParallelTranscodes);
|
||||
}
|
||||
@@ -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<IMediaProcessingLimits>();
|
||||
limits.MaxParallelTranscodes.Returns(parallel);
|
||||
return limits;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Автосборка сетки: раскладка по полосам профиля, подбор групп под рейтинг и тип, заполнение
|
||||
/// только дыр и пересборка с нуля. Главное свойство — предпросмотр и создание считают одно и то же.
|
||||
/// </summary>
|
||||
public class GenerateGridTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>Шоу с готовыми сериями: без длительности группа считалась бы пустой.</summary>
|
||||
private static (Show Show, List<MediaAsset> 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<MediaAsset>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Несколько полнометражек: у одиночного шоу серия ровно одна, поэтому кино — это N шоу.</summary>
|
||||
private static (List<Show> Shows, List<MediaAsset> Assets) Films(
|
||||
int count,
|
||||
int minutes,
|
||||
ShowAudience? audience = null
|
||||
)
|
||||
{
|
||||
var shows = new List<Show>();
|
||||
var assets = new List<MediaAsset>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Канал с пустой сеткой: один фоновый слой без слотов.</summary>
|
||||
private static async Task<(Guid ChannelId, ScheduleTemplate Template)> SeedChannelAsync(
|
||||
TestDb fixture,
|
||||
IEnumerable<Show> shows,
|
||||
IEnumerable<MediaAsset> assets,
|
||||
IEnumerable<Group> 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<GridPlan> 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);
|
||||
}
|
||||
}
|
||||
@@ -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. Порядок реализации
|
||||
|
||||
@@ -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<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
|
||||
}
|
||||
|
||||
/** Каталог профилей автосборки: имя, на что похоже и для какой библиотеки годится. */
|
||||
export function listGridProfiles() {
|
||||
return apiRequest<GridProfileDto[]>('/admin/grid-profiles')
|
||||
}
|
||||
|
||||
/** План автосборки: что будет создано и что снесено. Считается тем же кодом, что и сама сборка. */
|
||||
export function previewGrid(channelId: string, profile: GridProfileKind, mode: GridGenerationMode) {
|
||||
const query = new URLSearchParams({ profile, mode })
|
||||
return apiRequest<GridPlanDto>(
|
||||
`/admin/channels/${channelId}/template/grid-plan?${query.toString()}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Собирает сетку по профилю. План пересчитывается на сервере — с клиента едут только опции. */
|
||||
export function generateGrid(
|
||||
channelId: string,
|
||||
profile: GridProfileKind,
|
||||
mode: GridGenerationMode,
|
||||
) {
|
||||
return apiRequest<GenerateGridResultDto>(`/admin/channels/${channelId}/template/generate`, {
|
||||
method: 'POST',
|
||||
body: { profile, mode },
|
||||
})
|
||||
}
|
||||
|
||||
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
|
||||
export function copyTemplateTo(channelId: string, targetChannelId: string) {
|
||||
return apiRequest<CopyTemplateResultDto>(
|
||||
|
||||
@@ -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<GridProfileKind>('Mixed')
|
||||
const [mode, setMode] = useState<GridGenerationMode>('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 (
|
||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.channels.generate.title')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.channels.generate.hint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
{/* Профиль — то, ради чего окно и открыли: ритм суток задаёт всё остальное. */}
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{(profiles ?? []).map((item) => (
|
||||
<button
|
||||
key={item.kind}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-0.5 rounded-md border px-3 py-2 text-left transition-colors',
|
||||
item.kind === profile
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50',
|
||||
)}
|
||||
onClick={() => setProfile(item.kind)}
|
||||
>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.reference}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{item.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(['FillGaps', 'Rebuild'] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant={value === mode ? 'default' : 'outline'}
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
{t(`admin.channels.generate.modes.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(`admin.channels.generate.modeHints.${mode}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isFetching && <p className="text-muted-foreground">{t('common.loading')}</p>}
|
||||
|
||||
{plan && !isFetching && (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
{t('admin.channels.generate.summary', {
|
||||
slots: plan.slots.length,
|
||||
percent: covered,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{plan.slotsToRemove > 0 && (
|
||||
<p className="flex items-center gap-1.5 text-amber-500">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{t('admin.channels.generate.willRemove', { count: plan.slotsToRemove })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan.fallbackGroupName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.generate.fallback', { name: plan.fallbackGroupName })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan.notes.length > 0 && (
|
||||
<ul className="flex flex-col gap-0.5 text-xs text-amber-500">
|
||||
{plan.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{plan.slots.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t('admin.channels.generate.nothing')}</p>
|
||||
) : (
|
||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-xs">
|
||||
{plan.slots.map((slot, index) => (
|
||||
<PlanRow key={`${slot.weekday}-${slot.start}-${index}`} slot={slot} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={generate.isPending || isFetching || !plan || plan.slots.length === 0}
|
||||
onClick={() => generate.mutate()}
|
||||
>
|
||||
{t('admin.channels.generate.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanRow({ slot }: Readonly<{ slot: GridPlanSlotDto }>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 px-3 py-1">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">
|
||||
{slot.weekday === null
|
||||
? t('admin.channels.everyDay')
|
||||
: t(`admin.channels.weekdays.${slot.weekday}`)}
|
||||
</span>
|
||||
<span className="w-12 shrink-0 tabular-nums">{slot.start}</span>
|
||||
<span className="w-20 shrink-0 tabular-nums text-muted-foreground">
|
||||
<DurationLabel seconds={slot.durationMinutes * 60} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{slot.title}</span>
|
||||
<Badge variant="muted">{slot.block}</Badge>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [viewDate, setViewDate] = useState<string>('')
|
||||
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
// День, который копируем, и отмеченные дни-приёмники.
|
||||
const [copySource, setCopySource] = useState<number | null>(null)
|
||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||
@@ -302,6 +304,10 @@ export function GridTab({
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('admin.channels.newSlot')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setGenerating(true)}>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
{t('admin.channels.generate.action')}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
|
||||
<Input
|
||||
type="date"
|
||||
@@ -377,6 +383,15 @@ export function GridTab({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{generating && (
|
||||
<GenerateGridDialog
|
||||
channelId={channelId}
|
||||
onClose={() => setGenerating(false)}
|
||||
onGenerated={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{applicabilityLayer && (
|
||||
<LayerApplicabilityDialog
|
||||
layer={applicabilityLayer}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { toast } from '@/shared/ui/toast-store'
|
||||
import { useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||
import { formatDuration } from './format'
|
||||
import { formatDuration, splitEta } from './format'
|
||||
import { ManualInboxDialog } from './ManualInboxDialog'
|
||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||
import { useUploadStore } from './upload-store'
|
||||
@@ -145,6 +145,15 @@ export function MediaPanel() {
|
||||
{formatDuration(stats.averageProcessingSeconds)}
|
||||
</span>
|
||||
</span>
|
||||
{/* Оценка появляется только когда есть что ждать: «Осталось: —» бесполезно. */}
|
||||
{stats.estimatedRemainingSeconds !== null && (
|
||||
<span title={t('admin.media.stats.eta')}>
|
||||
{t('admin.media.stats.etaShort')}:{' '}
|
||||
<span className="text-foreground tabular-nums">
|
||||
<EtaValue seconds={stats.estimatedRemainingSeconds} />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Начало',
|
||||
|
||||
Reference in New Issue
Block a user