Implement template export/import endpoints and enhance grid generation logic
ci / build-backend (push) Successful in 1m31s
ci / build-frontend (push) Successful in 1m5s
ci / tests (push) Successful in 3m54s
ci / sonar (push) Successful in 4m40s

Added new endpoints for exporting and importing grid configurations in TemplateEndpoints, allowing for better management of template data. Enhanced the GenerateGridCommandHandler to support seasonal layers in grid generation, improving scheduling accuracy during holiday periods. Updated related classes and records to accommodate these changes, ensuring a cohesive integration of new features. Improved documentation for clarity and maintainability.
This commit is contained in:
Leonid Pershin
2026-07-28 02:15:04 +03:00
parent 7bee84c548
commit f7e7b5f7c3
45 changed files with 2929 additions and 242 deletions
@@ -12,6 +12,7 @@ using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.GetTemplate; using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers; using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.Restore; using TeleWave.Application.Programming.Templates.Restore;
using TeleWave.Application.Programming.Templates.Transfer;
using TeleWave.Application.Programming.Templates.UpdateSlot; using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate; using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
@@ -80,6 +81,18 @@ public static class TemplateEndpoints
.MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid) .MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid)
.Produces<GenerateGridResultDto>(); .Produces<GenerateGridResultDto>();
// Обмен конфигурацией: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ.
// Запрос никуда не отправляется — админ копирует его в свою модель и приносит ответ назад.
admin
.MapGet("/channels/{channelId:guid}/template/export", ExportGrid)
.Produces<GridConfig>();
admin
.MapPost("/channels/{channelId:guid}/template/import", ImportGrid)
.Produces<GridImportResultDto>();
admin
.MapPost("/channels/{channelId:guid}/template/ai-prompt", BuildPrompt)
.Produces<GridPromptDto>();
admin admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer) .MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
@@ -224,6 +237,44 @@ public static class TemplateEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ExportGrid(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ExportGridQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ImportGrid(
Guid channelId,
ImportGridBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportGridCommand(channelId, body.Config, body.Replace),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> BuildPrompt(
Guid channelId,
GridPromptBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new BuildGridPromptQuery(channelId, body.References ?? [], body.Notes),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CopyTemplate( private static async Task<IResult> CopyTemplate(
Guid channelId, Guid channelId,
Guid targetChannelId, Guid targetChannelId,
@@ -354,6 +405,10 @@ public sealed record UpdateTemplateBody(
public sealed record GenerateGridBody(GridProfileKind Profile, GridGenerationMode Mode); public sealed record GenerateGridBody(GridProfileKind Profile, GridGenerationMode Mode);
public sealed record ImportGridBody(GridConfig Config, bool Replace);
public sealed record GridPromptBody(IReadOnlyList<string>? References, string? Notes);
public sealed record CreateLayerBody(string Name, int Priority); public sealed record CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody( public sealed record UpdateLayerBody(
@@ -20,9 +20,15 @@ public sealed record BumperContext(
string? NextGenre = null, string? NextGenre = null,
/// <summary>Во сколько начнётся следующая программа (время канала).</summary> /// <summary>Во сколько начнётся следующая программа (время канала).</summary>
TimeOnly? NextTime = null, TimeOnly? NextTime = null,
string? SlotTitle = null string? SlotTitle = null,
/// <summary>Что и когда идёт вечером этих вещательных суток — для анонсов.</summary>
BumperAnnouncement? Tonight = null,
BumperAnnouncement? Tomorrow = null
); );
/// <summary>Анонсируемая программа: что и во сколько. Null — анонсировать нечего, строка схлопнется.</summary>
public sealed record BumperAnnouncement(string Title, TimeOnly Time);
/// <summary> /// <summary>
/// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30». /// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30».
/// ///
@@ -43,6 +49,10 @@ public static partial class BumperPlaceholders
"next.year", "next.year",
"next.genre", "next.genre",
"next.time", "next.time",
"tonight.title",
"tonight.time",
"tomorrow.title",
"tomorrow.time",
"time", "time",
"date", "date",
"weekday", "weekday",
@@ -79,16 +89,33 @@ public static partial class BumperPlaceholders
/// по краям убираются: «СЕЙЧАС — {now.title}» без текущей программы должно стать «СЕЙЧАС», /// по краям убираются: «СЕЙЧАС — {now.title}» без текущей программы должно стать «СЕЙЧАС»,
/// а не «СЕЙЧАС —». /// а не «СЕЙЧАС —».
/// ///
/// Висящие предлоги («ДАЛЕЕ В») при этом остаются: отличить предлог от слова в тексте, который /// Строка, в которой не подставился ни один плейсхолдер, исчезает целиком: она и писалась ради
/// пишет человек на любом языке, нельзя, а угадывать в эфире — хуже, чем оставить как есть. /// данных, а «ДАЛЕЕ В» без времени — это не подпись, а мусор в кадре. Строки без плейсхолдеров
/// это не касается: они и есть постоянный текст.
/// </summary> /// </summary>
public static string Resolve(string text, BumperContext context) public static string Resolve(string text, BumperContext context)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
return string.Empty; return string.Empty;
var tokens = 0;
var filled = 0;
var resolved = TokenPattern() var resolved = TokenPattern()
.Replace(text, match => Value(match.Groups[1].Value, context) ?? string.Empty); .Replace(
text,
match =>
{
tokens++;
var value = Value(match.Groups[1].Value, context);
if (!string.IsNullOrWhiteSpace(value))
filled++;
return value ?? string.Empty;
}
);
if (tokens > 0 && filled == 0)
return string.Empty;
return ExtraSpaces().Replace(resolved, " ").Trim().Trim(DanglingSeparators).Trim(); return ExtraSpaces().Replace(resolved, " ").Trim().Trim(DanglingSeparators).Trim();
} }
@@ -134,6 +161,10 @@ public static partial class BumperPlaceholders
"next.year" => context.NextYear?.ToString(Culture), "next.year" => context.NextYear?.ToString(Culture),
"next.genre" => context.NextGenre, "next.genre" => context.NextGenre,
"next.time" => context.NextTime?.ToString("HH:mm", Culture), "next.time" => context.NextTime?.ToString("HH:mm", Culture),
"tonight.title" => context.Tonight?.Title,
"tonight.time" => context.Tonight?.Time.ToString("HH:mm", Culture),
"tomorrow.title" => context.Tomorrow?.Title,
"tomorrow.time" => context.Tomorrow?.Time.ToString("HH:mm", Culture),
"time" => TimeOnly "time" => TimeOnly
.FromDateTime(context.LocalMoment.DateTime) .FromDateTime(context.LocalMoment.DateTime)
.ToString("HH:mm", Culture), .ToString("HH:mm", Culture),
@@ -105,7 +105,10 @@ public sealed class RenderBumperPreviewCommandHandler(
samples.NextYear, samples.NextYear,
samples.NextGenre, samples.NextGenre,
TimeOnly.FromDateTime(moment.AddMinutes(30).DateTime), TimeOnly.FromDateTime(moment.AddMinutes(30).DateTime),
samples.SlotTitle samples.SlotTitle,
// Анонсы в превью показываются образцом: настоящий вечер известен только ленте.
new BumperAnnouncement(samples.NextTitle, new TimeOnly(20, 0)),
new BumperAnnouncement(samples.NowTitle, new TimeOnly(20, 0))
); );
} }
@@ -48,6 +48,7 @@ public static class DependencyInjection
services.AddScoped<GroupSuggestionBuilder>(); services.AddScoped<GroupSuggestionBuilder>();
services.AddScoped<GroupMembershipCleaner>(); services.AddScoped<GroupMembershipCleaner>();
services.AddScoped<SlotWriter>(); services.AddScoped<SlotWriter>();
services.AddScoped<GroupCatalog>();
services.AddScoped<GridPlanner>(); services.AddScoped<GridPlanner>();
services.AddScoped<GroupExpander>(); services.AddScoped<GroupExpander>();
services.AddScoped<BumperResolver>(); services.AddScoped<BumperResolver>();
@@ -3,6 +3,7 @@ using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning; using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning; namespace TeleWave.Application.Programming.Planning;
@@ -20,20 +21,20 @@ internal sealed class BumperFacts
private readonly TimeSpan _offset; private readonly TimeSpan _offset;
private readonly Channel _channel; private readonly Channel _channel;
private readonly IReadOnlyDictionary<Guid, ShowFact> _shows; private readonly IReadOnlyDictionary<Guid, ShowFact> _shows;
private readonly IReadOnlyDictionary<Guid, string> _slotTitles; private readonly IReadOnlyDictionary<Guid, SlotFact> _slots;
private BumperFacts( private BumperFacts(
IReadOnlyList<PlannedItem> items, IReadOnlyList<PlannedItem> items,
Channel channel, Channel channel,
IReadOnlyDictionary<Guid, ShowFact> shows, IReadOnlyDictionary<Guid, ShowFact> shows,
IReadOnlyDictionary<Guid, string> slotTitles IReadOnlyDictionary<Guid, SlotFact> slots
) )
{ {
_items = items; _items = items;
_channel = channel; _channel = channel;
_offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes); _offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
_shows = shows; _shows = shows;
_slotTitles = slotTitles; _slots = slots;
} }
public static async Task<BumperFacts> LoadAsync( public static async Task<BumperFacts> LoadAsync(
@@ -44,9 +45,17 @@ internal sealed class BumperFacts
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var announces = tokens.Any(token =>
token.StartsWith("tonight.") || token.StartsWith("tomorrow.")
);
// Имена нужны соседям заставки, а при анонсах — и всем программам ленты: анонсировать
// можно то, что стоит через несколько часов, а не только следующее.
var showIds = items var showIds = items
.Where(i => i.Kind == PlannedItemKind.Bumper) .Where(i =>
.SelectMany(i => new[] { i.FromShowId, i.ToShowId }) i.Kind == PlannedItemKind.Bumper || (announces && i.Kind == PlannedItemKind.Program)
)
.SelectMany(i => new[] { i.FromShowId, i.ToShowId, i.ShowId })
.Where(id => id is not null && id != Guid.Empty) .Where(id => id is not null && id != Guid.Empty)
.Select(id => id!.Value) .Select(id => id!.Value)
.Distinct() .Distinct()
@@ -57,11 +66,14 @@ internal sealed class BumperFacts
? [] ? []
: await LoadShowsAsync(dbContext, showIds, tokens, cancellationToken); : await LoadShowsAsync(dbContext, showIds, tokens, cancellationToken);
var slotTitles = tokens.Contains("slot") // Слоты нужны и названию, и анонсам: «сегодня вечером» — это первая программа прайма,
? await LoadSlotTitlesAsync(dbContext, items, cancellationToken) // а что считать праймом, решает дейпарт слота, проставленный админом.
: []; var needsSlots = tokens.Any(token =>
token == "slot" || token.StartsWith("tonight.") || token.StartsWith("tomorrow.")
);
var slots = needsSlots ? await LoadSlotsAsync(dbContext, items, cancellationToken) : [];
return new BumperFacts(items, channel, shows, slotTitles); return new BumperFacts(items, channel, shows, slots);
} }
/// <summary>Контекст одной заставки: соседи по ленте, время показа и данные канала.</summary> /// <summary>Контекст одной заставки: соседи по ленте, время показа и данные канала.</summary>
@@ -85,12 +97,58 @@ internal sealed class BumperFacts
nextProgram is { } next nextProgram is { } next
? TimeOnly.FromDateTime(next.StartsAtUtc.ToOffset(_offset).DateTime) ? TimeOnly.FromDateTime(next.StartsAtUtc.ToOffset(_offset).DateTime)
: null, : null,
item.SlotId is { } slotId && _slotTitles.TryGetValue(slotId, out var title) item.SlotId is { } slotId && _slots.TryGetValue(slotId, out var slot)
? title ? slot.Title
: null : null,
Announcement(index, item, BroadcastDate(item.StartsAtUtc)),
Announcement(index, item, BroadcastDate(item.StartsAtUtc).AddDays(1))
); );
} }
/// <summary>
/// Что анонсировать на указанные вещательные сутки: первая программа прайма, которая ещё
/// не началась. Анонс «сегодня в 20:00», сказанный в девять вечера, — хуже, чем его отсутствие,
/// поэтому смотрим только вперёд по ленте.
/// </summary>
private BumperAnnouncement? Announcement(int index, PlannedItem bumper, DateOnly date)
{
for (var i = index + 1; i < _items.Count; i++)
{
var item = _items[i];
if (item.Kind != PlannedItemKind.Program || item.ShowId is not { } showId)
continue;
if (BroadcastDate(item.StartsAtUtc) != date)
continue;
if (item.SlotId is not { } slotId || _slots.GetValueOrDefault(slotId)?.IsPrime != true)
continue;
if (item.StartsAtUtc <= bumper.EndsAtUtc)
continue;
if (_shows.GetValueOrDefault(showId) is not { } show)
continue;
return new BumperAnnouncement(
show.Name,
TimeOnly.FromDateTime(item.StartsAtUtc.ToOffset(_offset).DateTime)
);
}
return null;
}
/// <summary>
/// Вещательные сутки момента: ночной блок до <see cref="Channel.DayStartTime"/> относится
/// к предыдущему дню, иначе «сегодня вечером» в час ночи означало бы уже следующий вечер.
/// </summary>
private DateOnly BroadcastDate(DateTimeOffset momentUtc)
{
var local = momentUtc.ToOffset(_offset);
var date = DateOnly.FromDateTime(local.DateTime);
return TimeOnly.FromDateTime(local.DateTime) >= _channel.DayStartTime
? date
: date.AddDays(-1);
}
/// <summary>Подпись серии соседней программы — только если это та же самая программа.</summary> /// <summary>Подпись серии соседней программы — только если это та же самая программа.</summary>
private static string? EpisodeOf(ShowFact? show, PlannedItem? neighbour, Guid? showId) private static string? EpisodeOf(ShowFact? show, PlannedItem? neighbour, Guid? showId)
{ {
@@ -195,7 +253,7 @@ internal sealed class BumperFacts
); );
} }
private static async Task<Dictionary<Guid, string>> LoadSlotTitlesAsync( private static async Task<Dictionary<Guid, SlotFact>> LoadSlotsAsync(
IAppDbContext dbContext, IAppDbContext dbContext,
IReadOnlyList<PlannedItem> items, IReadOnlyList<PlannedItem> items,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -211,9 +269,16 @@ internal sealed class BumperFacts
return await dbContext return await dbContext
.Slots.AsNoTracking() .Slots.AsNoTracking()
.Where(s => slotIds.Contains(s.Id)) .Where(s => slotIds.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken); .ToDictionaryAsync(
s => s.Id,
s => new SlotFact(s.Title, s.Daypart == Daypart.Prime),
cancellationToken
);
} }
/// <summary>Слот глазами заставки: как он называется и прайм ли это.</summary>
private sealed record SlotFact(string Title, bool IsPrime);
private sealed record ShowFact( private sealed record ShowFact(
string Name, string Name,
int? Year, int? Year,
@@ -391,6 +391,15 @@ public sealed class GridScheduleGenerator(
var fallback = await LoadFallbackUnitsAsync(channel, template, cancellationToken); var fallback = await LoadFallbackUnitsAsync(channel, template, cancellationToken);
// Чем добивается шов до круглой отметки: ролики и анонсы стыка канала по умолчанию.
var padJunction = BuildJunction(
template.DefaultJunctionId,
junctions,
elementsByGroup,
bumperTemplates,
Daypart.Day
);
return new PlanningInput( return new PlanningInput(
channel.Id, channel.Id,
startUtc, startUtc,
@@ -399,10 +408,32 @@ public sealed class GridScheduleGenerator(
fallback, fallback,
_segmentSeconds, _segmentSeconds,
channel.UtcOffsetMinutes, channel.UtcOffsetMinutes,
grid.Background.Select(s => planned[s]).ToList() grid.Background.Select(s => planned[s]).ToList(),
PadUnits(padJunction),
padJunction?.MaxTotal ?? PlanningInput.DefaultMaxPad
); );
} }
/// <summary>
/// Ролики и анонсы стыка, распрямлённые в плоский список для добора шва. Заставки сюда
/// не попадают: их ассет рендерится под конкретную пару соседей, а шов — это ровно то место,
/// где пары ещё нет.
/// </summary>
private static IReadOnlyList<PadUnit> PadUnits(PlanningJunction? junction) =>
junction is null
? []
: junction
.Elements.Where(e => e.Kind != JunctionElementKind.Bumper && e.Units.Count > 0)
.SelectMany(e =>
e.Units.Select(unit => new PadUnit(
unit,
e.Kind == JunctionElementKind.Promo
? PlannedItemKind.Promo
: PlannedItemKind.Ad
))
)
.ToList();
/// <summary>Разворачивает шаблон стыка для планировщика, включая резерв под заставки.</summary> /// <summary>Разворачивает шаблон стыка для планировщика, включая резерв под заставки.</summary>
private PlanningJunction? BuildJunction( private PlanningJunction? BuildJunction(
Guid? junctionId, Guid? junctionId,
@@ -443,6 +474,7 @@ public sealed class GridScheduleGenerator(
conditions.TimeWindow is { } window conditions.TimeWindow is { } window
? new PlanningTimeWindow(window.From, window.To) ? new PlanningTimeWindow(window.From, window.To)
: null, : null,
conditions.NearHourMinutes,
element.ChoiceKey, element.ChoiceKey,
element.ChoiceWeight, element.ChoiceWeight,
element.BumperTemplateId, element.BumperTemplateId,
@@ -24,6 +24,9 @@ public sealed class GenerateGridCommandHandler(
private const int MainPriority = 10; private const int MainPriority = 10;
private const int WeekendPriority = 20; private const int WeekendPriority = 20;
/// <summary>Праздники перекрывают и будни, и выходные: 1 января — это не «обычное воскресенье».</summary>
private const int SeasonPriority = 30;
public async Task<Result<GenerateGridResultDto>> Handle( public async Task<Result<GenerateGridResultDto>> Handle(
GenerateGridCommand command, GenerateGridCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
@@ -53,7 +56,7 @@ public sealed class GenerateGridCommandHandler(
var created = 0; var created = 0;
foreach (var slot in plan.Slots) foreach (var slot in plan.Slots)
{ {
var layer = EnsureLayer(template, slot.Layer); var layer = EnsureLayer(template, slot.Layer, plan.Profile.Season);
var applied = await writer.ApplyAsync(layer, null, ToInput(slot), cancellationToken); var applied = await writer.ApplyAsync(layer, null, ToInput(slot), cancellationToken);
if (!applied.IsSuccess) if (!applied.IsSuccess)
return Result.Failure<GenerateGridResultDto>(applied.Error); return Result.Failure<GenerateGridResultDto>(applied.Error);
@@ -86,17 +89,38 @@ public sealed class GenerateGridCommandHandler(
return removed; return removed;
} }
private static GridLayer EnsureLayer(ScheduleTemplate template, GridPlanLayer kind) /// <summary>
/// Слой под слоты плана. Существующий переиспользуется по имени, новый — заводится; применимость
/// проставляется только новому: даты праздничного слоя админ мог подправить под свой канал,
/// и повторная генерация не должна их затирать.
/// </summary>
private static GridLayer EnsureLayer(
ScheduleTemplate template,
GridPlanLayer kind,
GridSeason? season
)
{ {
var (name, priority) = var (name, priority) = kind switch
kind == GridPlanLayer.Weekend {
? (GridPlanner.WeekendLayerName, WeekendPriority) GridPlanLayer.Weekend => (GridPlanner.WeekendLayerName, WeekendPriority),
: (GridPlanner.MainLayerName, MainPriority); GridPlanLayer.Season => (season?.Name ?? "Праздники", SeasonPriority),
_ => (GridPlanner.MainLayerName, MainPriority),
};
return template.Layers.FirstOrDefault(l => !l.IsBackground && l.Name == name) if (template.Layers.FirstOrDefault(l => !l.IsBackground && l.Name == name) is { } existing)
?? template.AddLayer(name, priority); return existing;
var layer = template.AddLayer(name, priority);
if (kind == GridPlanLayer.Season && season is not null)
layer.Update(name, priority, Applicability(season).ToJson(), isEnabled: true);
return layer;
} }
/// <summary>Когда действует праздничный слой — ежегодный период профиля.</summary>
private static LayerApplicability Applicability(GridSeason season) =>
new(AnnualRanges: [season.Range]);
private static SlotInput ToInput(PlannedSlot slot) => private static SlotInput ToInput(PlannedSlot slot) =>
new( new(
slot.Title, slot.Title,
@@ -113,6 +137,11 @@ public sealed class GenerateGridCommandHandler(
OverflowPolicy.ContinueNext, OverflowPolicy.ContinueNext,
slot.IsAnchor, slot.IsAnchor,
Slot.DefaultMaxDriftMinutes, Slot.DefaultMaxDriftMinutes,
null // Округление старта до четверти часа: программа передач не начинается в 19:47, а разницу
// добирает рекламный шов. Мягкое — если добирать пришлось бы дольше допуска, пропустится.
SnapMinutes
); );
/// <summary>Шаг округления стартов у собранных слотов — та же решётка, что и у длин.</summary>
private const int SnapMinutes = 15;
} }
@@ -17,6 +17,9 @@ public enum GridPlanLayer
{ {
Main = 0, Main = 0,
Weekend = 1, Weekend = 1,
/// <summary>Праздничная сетка: слой поверх основного, действующий только в свои даты.</summary>
Season = 2,
} }
/// <summary> /// <summary>
@@ -21,11 +21,7 @@ namespace TeleWave.Application.Programming.Templates.Generate;
/// на настоящем ТВ; жанр не занимает подряд две полосы. Подбор при этом остаётся объяснимым — /// на настоящем ТВ; жанр не занимает подряд две полосы. Подбор при этом остаётся объяснимым —
/// жёсткий отсев по рейтингу, дальше баллы. /// жёсткий отсев по рейтингу, дальше баллы.
/// </summary> /// </summary>
public sealed class GridPlanner( public sealed class GridPlanner(IAppDbContext dbContext, GroupCatalog groups)
IAppDbContext dbContext,
DynamicGroupResolver dynamicResolver,
GroupElementResolver elementResolver
)
{ {
public const string MainLayerName = "Основная сетка"; public const string MainLayerName = "Основная сетка";
public const string WeekendLayerName = "Выходные"; public const string WeekendLayerName = "Выходные";
@@ -34,7 +30,19 @@ public sealed class GridPlanner(
private const int MinSlotMinutes = 15; private const int MinSlotMinutes = 15;
/// <summary>Длины слотов округляются до пяти минут: сетка должна читаться, а не считаться.</summary> /// <summary>Длины слотов округляются до пяти минут: сетка должна читаться, а не считаться.</summary>
private const int SlotStepMinutes = 5; /// <summary>
/// Шаг решётки времени. Программа передач состоит из круглых времён — 19:45, 20:00, 20:30, —
/// и именно это подсознательно отличает телеканал от выгрузки плейлиста. Длина слота округляется
/// вверх до шага, а разницу между контентом и отметкой съедает рекламный шов.
/// </summary>
private const int SlotStepMinutes = 15;
/// <summary>
/// Шаг округления средней длины серии. Мельче решётки времени намеренно: это оценка контента,
/// а не отметка в программе, и округлив 25-минутную серию до получаса, мы отдали бы шву
/// пять лишних минут на каждой.
/// </summary>
private const int UnitStepMinutes = 5;
/// <summary>Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один.</summary> /// <summary>Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один.</summary>
private const int FeatureLengthMinutes = 60; private const int FeatureLengthMinutes = 60;
@@ -64,14 +72,14 @@ public sealed class GridPlanner(
if (template is null) if (template is null)
return Result.Failure<GridPlan>(ChannelErrors.TemplateNotFound); return Result.Failure<GridPlan>(ChannelErrors.TemplateNotFound);
var groups = await LoadGroupsAsync(cancellationToken); var candidates = await groups.LoadAsync(cancellationToken);
if (groups.Count == 0) if (candidates.Count == 0)
return Result.Failure<GridPlan>(TemplateErrors.NoGroupsToGenerate); return Result.Failure<GridPlan>(TemplateErrors.NoGroupsToGenerate);
var profile = GridProfiles.Get(profileKind); var profile = GridProfiles.Get(profileKind);
var run = new PlanRun( var run = new PlanRun(
profile, profile,
groups, candidates,
channel.DayStartTime, channel.DayStartTime,
PlanningRules.FromJson(template.RulesJson) PlanningRules.FromJson(template.RulesJson)
); );
@@ -92,12 +100,20 @@ public sealed class GridPlanner(
// Аварийная группа отдаётся отдельным полем, а не замечанием: замечания — про то, что пошло // Аварийная группа отдаётся отдельным полем, а не замечанием: замечания — про то, что пошло
// не так, а это штатное действие, и в предпросмотре у него своя строка. // не так, а это штатное действие, и в предпросмотре у него своя строка.
var fallback = groups.MaxBy(g => g.UnitCount); var fallback = candidates.MaxBy(g => g.UnitCount);
var needsFallback = template.FallbackGroupId is null && fallback is not null; var needsFallback = template.FallbackGroupId is null && fallback is not null;
var everyDaySpan = EveryDaySpan(mode, profile); var everyDaySpan = EveryDaySpan(mode, profile);
var covered = slots.Sum(s => s.DurationMinutes * (s.Weekday is null ? everyDaySpan : 1)); var covered = slots.Sum(s => s.DurationMinutes * (s.Weekday is null ? everyDaySpan : 1));
// Праздничный слой строится отдельным разбором: это не добавка к неделе, а замена ей на
// свои даты, и общий бюджет группы он тратить не должен — иначе обычная неделя обеднеет
// ради недели, которая идёт раз в год.
var season =
mode == GridGenerationMode.Rebuild && profile.Season is { } holidays
? BuildSeason(holidays, run)
: [];
var notes = new List<PlanNote>(run.Notes); var notes = new List<PlanNote>(run.Notes);
notes.AddRange(run.WeeklyChecks()); notes.AddRange(run.WeeklyChecks());
notes.AddRange(AudienceChecks(slots, run)); notes.AddRange(AudienceChecks(slots, run));
@@ -114,7 +130,7 @@ public sealed class GridPlanner(
new GridPlan( new GridPlan(
profile, profile,
mode, mode,
slots, [.. slots, .. season],
notes, notes,
mode == GridGenerationMode.Rebuild ? existing.Count : 0, mode == GridGenerationMode.Rebuild ? existing.Count : 0,
needsFallback ? fallback!.Id : null, needsFallback ? fallback!.Id : null,
@@ -125,6 +141,24 @@ public sealed class GridPlanner(
); );
} }
/// <summary>
/// Раскладка праздничных суток. День строится один и ложится слотами «каждый день»: слой
/// действует несколько дат подряд, и расписывать их по дням недели значило бы получить сетку,
/// которая зависит от того, на какой день выпало 31 декабря.
/// </summary>
private static List<PlannedSlot> BuildSeason(GridSeason season, PlanRun source)
{
var run = new PlanRun(source.Profile, source.Groups, source.DayStart, source.Rules);
run.BeginDay();
return FillDay(
season.Bands,
[(0, GridCoverage.MinutesInDay)],
new SlotTarget(null, GridPlanLayer.Season),
run
);
}
/// <summary>Сколько дней в неделю действует слот «каждый день»: выходные могут быть перекрыты.</summary> /// <summary>Сколько дней в неделю действует слот «каждый день»: выходные могут быть перекрыты.</summary>
private static int EveryDaySpan(GridGenerationMode mode, GridProfile profile) => private static int EveryDaySpan(GridGenerationMode mode, GridProfile profile) =>
mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7; mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7;
@@ -387,9 +421,10 @@ public sealed class GridPlanner(
return (Fit(Band.BlockMinutes > 0 ? Band.BlockMinutes : available, available), 0); return (Fit(Band.BlockMinutes > 0 ? Band.BlockMinutes : available, available), 0);
var duration = Fit(Math.Max(1, Band.UnitsPerBlock) * unit, available); var duration = Fit(Math.Max(1, Band.UnitsPerBlock) * unit, available);
// Число единиц — по фактической длине слота: полоса задаёт замысел, а сколько влезло, // Число единиц — сколько их целиком влезает в слот: полоса задаёт замысел, а сколько
// решает контент. // поместилось, решает контент. Округление вверх до решётки оставляет хвост под рекламу,
return (duration, Math.Max(1, (int)Math.Round(duration / (double)unit))); // и брать за него ещё одну серию нельзя — она перелезет через следующую отметку.
return (duration, Math.Max(1, duration / unit));
} }
public PlannedSlot Content(GroupCandidate group, int available) public PlannedSlot Content(GroupCandidate group, int available)
@@ -474,12 +509,18 @@ public sealed class GridPlanner(
private static int RepeatMinutes(GridBand band) => private static int RepeatMinutes(GridBand band) =>
band.BlockMinutes > 0 ? band.BlockMinutes : 120; band.BlockMinutes > 0 ? band.BlockMinutes : 120;
private static int Round(int minutes) => /// <summary>
Math.Max(SlotStepMinutes, minutes / SlotStepMinutes * SlotStepMinutes); /// Вверх до шага решётки: слот не должен быть короче своего контента, иначе блок перелезет
/// через объявленное время. Разницу между контентом и отметкой занимает рекламный шов.
/// </summary>
private static int Round(int minutes, int step = SlotStepMinutes) =>
Math.Max(step, (int)Math.Ceiling(minutes / (double)step) * step);
/// <summary>Средняя длина единицы группы, округлённая до пяти минут (0 — длительностей нет).</summary> /// <summary>Средняя длина единицы группы, округлённая до пяти минут (0 — длительностей нет).</summary>
private static int UnitMinutes(GroupCandidate group) => private static int UnitMinutes(GroupCandidate group) =>
group.AverageUnitMinutes <= 0 ? 0 : Round((int)Math.Round(group.AverageUnitMinutes)); group.AverageUnitMinutes <= 0
? 0
: Round((int)Math.Round(group.AverageUnitMinutes), UnitStepMinutes);
/// <summary> /// <summary>
/// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной /// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной
@@ -503,11 +544,18 @@ public sealed class GridPlanner(
if (ranked.Count == 0) if (ranked.Count == 0)
return null; return null;
if (!band.Rotate || target.Weekday is not { } weekday)
return ranked[0];
var day = Array.IndexOf(GridCoverage.Week, weekday); if (band.Rotate && target.Weekday is { } weekday)
return ranked[Math.Max(0, day) % ranked.Count]; {
// Ротация — это сдвиг по кругу, а не другой порядок: лучший кандидат остаётся лучшим,
// просто в понедельник он первый, во вторник второй.
var day = Math.Max(0, Array.IndexOf(GridCoverage.Week, weekday));
ranked = [.. ranked.Skip(day % ranked.Count), .. ranked.Take(day % ranked.Count)];
}
// Та же группа встык допускается только если больше поставить нечего: балл её уже штрафует,
// но при двух кандидатах штраф проигрывает разнице в размере группы.
return ranked.FirstOrDefault(g => !run.FollowsSameGroup(g)) ?? ranked[0];
} }
private static bool Allowed(GridBand band, GroupCandidate group) private static bool Allowed(GridBand band, GroupCandidate group)
@@ -546,6 +594,11 @@ public sealed class GridPlanner(
if (run.FollowsSameGenre(group)) if (run.FollowsSameGenre(group))
score -= 25; score -= 25;
// Та же группа встык — уже не ритм, а признак нехватки контента: штраф тяжелее жанрового,
// но не запрет: когда в библиотеке одна группа, ставить всё равно нечего.
if (run.FollowsSameGroup(group))
score -= 90;
if (run.Rules?.MaxGenreSharePercent is { } maxShare && run.GenreShare(group) >= maxShare) if (run.Rules?.MaxGenreSharePercent is { } maxShare && run.GenreShare(group) >= maxShare)
score -= 60; score -= 60;
@@ -585,113 +638,4 @@ public sealed class GridPlanner(
); );
} }
} }
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 genreId = await PrimaryGenreAsync(composition, cancellationToken);
if (Describe(group, composition, info, genreId) is { } candidate)
candidates.Add(candidate);
}
return candidates;
}
/// <summary>
/// Основной жанр группы — самый частый среди её шоу. Нужен, чтобы не ставить подряд две полосы
/// одного жанра и не выйти за потолок доли жанра из правил канала.
/// </summary>
private async Task<Guid?> PrimaryGenreAsync(
IReadOnlyList<ResolvedElement> composition,
CancellationToken cancellationToken
)
{
var showIds = composition
.Where(e => e.Kind == GroupElementKind.Show)
.Select(e => e.Id)
.ToList();
var collectionIds = composition
.Where(e => e.Kind == GroupElementKind.Collection)
.Select(e => e.Id)
.ToList();
if (collectionIds.Count > 0)
{
var parts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => i.ShowId)
.ToListAsync(cancellationToken);
showIds.AddRange(parts);
}
if (showIds.Count == 0)
return null;
var genres = await dbContext
.ShowGenres.AsNoTracking()
.Where(g => showIds.Contains(g.ShowId) && g.IsPrimary)
.Select(g => g.GenreId)
.ToListAsync(cancellationToken);
return genres.Count == 0
? null
: genres.GroupBy(id => id).OrderByDescending(g => g.Count()).First().Key;
}
/// <summary>
/// Сводит вычисленный состав к тому, что нужно раскладке: сколько эфира группа даёт, какой она
/// в основном природы и насколько строгий в ней контент. Группа без готовых единиц не годится
/// вовсе — слот по ней встал бы, а место закрыл фон.
/// </summary>
private static GroupCandidate? Describe(
Group group,
IReadOnlyList<ResolvedElement> composition,
IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo> info,
Guid? primaryGenreId
)
{
var resolved = composition
.Select(e => info.GetValueOrDefault((e.Kind, e.Id)))
.OfType<GroupElementInfo>()
.ToList();
var units = resolved.Sum(r => r.UnitCount);
if (units == 0)
return null;
// У коллекции своего типа нет: франшиза — это почти всегда полнометражки.
var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount);
var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration);
return new GroupCandidate(
group.Id,
group.Name,
composition.Count,
units,
resolved.Max(r => r.Audience),
seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single,
duration.TotalMinutes / units,
primaryGenreId
);
}
} }
@@ -54,6 +54,13 @@ public sealed record GridBand(
} }
} }
/// <summary>
/// Сезонный слой профиля: как выглядят сутки в праздничный период. Слой ложится поверх основного
/// и действует только в свои даты — на настоящем ТВ праздничная сетка ровно этим и является,
/// отдельной программой на неделю, а не правкой обычной.
/// </summary>
public sealed record GridSeason(string Name, AnnualRange Range, IReadOnlyList<GridBand> Bands);
/// <summary> /// <summary>
/// Готовый профиль сетки: как выглядят вещательные сутки у канала такого типа. Профили списаны /// Готовый профиль сетки: как выглядят вещательные сутки у канала такого типа. Профили списаны
/// с реальных каналов намеренно — «как у 2×2» админ проверяет по памяти, а «универсальный /// с реальных каналов намеренно — «как у 2×2» админ проверяет по памяти, а «универсальный
@@ -67,5 +74,7 @@ public sealed record GridProfile(
string Reference, string Reference,
string Description, string Description,
IReadOnlyList<GridBand> Weekdays, IReadOnlyList<GridBand> Weekdays,
IReadOnlyList<GridBand> Weekend IReadOnlyList<GridBand> Weekend,
/// <summary>Праздничная сетка профиля (null — сезонного слоя у профиля нет).</summary>
GridSeason? Season = null
); );
@@ -13,8 +13,20 @@ namespace TeleWave.Application.Programming.Templates.Generate;
/// </summary> /// </summary>
public static class GridProfiles public static class GridProfiles
{ {
// Праздничная сетка одна и та же у всех профилей, поэтому она навешивается здесь, а не
// повторяется шесть раз в описаниях: профиль отличают будни, а не Новый год.
public static IReadOnlyList<GridProfile> All { get; } = public static IReadOnlyList<GridProfile> All { get; } =
[Mixed(), Animation(), Sitcom(), Music(), Movies(), Kids()]; [.. Profiles().Select(profile => profile with { Season = Holidays() })];
private static IEnumerable<GridProfile> Profiles()
{
yield return Mixed();
yield return Animation();
yield return Sitcom();
yield return Music();
yield return Movies();
yield return Kids();
}
public static GridProfile Get(GridProfileKind kind) => public static GridProfile Get(GridProfileKind kind) =>
All.FirstOrDefault(p => p.Kind == kind) ?? All[0]; All.FirstOrDefault(p => p.Kind == kind) ?? All[0];
@@ -48,6 +60,67 @@ public static class GridProfiles
CooldownDays: 7 CooldownDays: 7
); );
/// <summary>
/// Праздничная сетка. Одна на все профили намеренно: в новогодние дни каналы сходятся к одному
/// и тому же — днём марафон, вечером кино, — и различает их библиотека, а не ритм суток.
///
/// Период пересекает Новый год, поэтому он ежегодный, а не диапазон дат: заводить слой заново
/// каждый декабрь админ не должен.
/// </summary>
private static GridSeason Holidays() =>
new(
"Праздничная неделя",
new AnnualRange(12, 25, 1, 8),
[
new(
At(6),
At(10),
Daypart.Morning,
"Праздничное утро",
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 120,
UnitsPerBlock: 4
),
new(
At(10),
At(16),
Daypart.Day,
"Праздничный марафон",
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 180
),
new(
At(16),
At(19),
Daypart.Day,
"Семейное кино",
ShowKind.Single,
ShowAudience.Pg,
BlockMinutes: 150,
UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 21
),
new(
At(19),
At(23),
Daypart.Prime,
"Праздничный прайм",
ShowKind.Single,
ShowAudience.Pg13,
BlockMinutes: 150,
UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 21,
Rotate: true
),
AdultNight(At(23), At(2)),
new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240),
]
);
private static GridProfile Mixed() => private static GridProfile Mixed() =>
new( new(
GridProfileKind.Mixed, GridProfileKind.Mixed,
@@ -84,7 +157,8 @@ public static class GridProfiles
ShowKind.Series, ShowKind.Series,
ShowAudience.Pg13, ShowAudience.Pg13,
BlockMinutes: 120, BlockMinutes: 120,
UnitsPerBlock: 3 UnitsPerBlock: 3,
Rotate: true
), ),
new( new(
At(18), At(18),
@@ -196,7 +270,8 @@ public static class GridProfiles
ShowKind.Series, ShowKind.Series,
ShowAudience.Pg13, ShowAudience.Pg13,
BlockMinutes: 150, BlockMinutes: 150,
UnitsPerBlock: 5 UnitsPerBlock: 5,
Rotate: true
), ),
new( new(
At(19), At(19),
@@ -251,8 +326,8 @@ public static class GridProfiles
GridProfileKind.Sitcom, GridProfileKind.Sitcom,
"Ситкомы", "Ситкомы",
"как у Paramount Comedy", "как у Paramount Comedy",
"Полосы получасовых серий по 3–4 подряд весь день, комедийная полнометражка вечером, " "Получасовые серии парами весь день — между парами рекламный шов, как в настоящем "
+ "ночью взрослый юмор.", + "ситкомовском эфире. Вечером комедийная полнометражка, ночью взрослый юмор.",
[ [
new( new(
At(6), At(6),
@@ -261,8 +336,8 @@ public static class GridProfiles
"Утренние ситкомы", "Утренние ситкомы",
ShowKind.Series, ShowKind.Series,
ShowAudience.Pg13, ShowAudience.Pg13,
BlockMinutes: 90, BlockMinutes: 60,
UnitsPerBlock: 3 UnitsPerBlock: 2
), ),
new( new(
At(12), At(12),
@@ -271,8 +346,9 @@ public static class GridProfiles
"Дневные ситкомы", "Дневные ситкомы",
ShowKind.Series, ShowKind.Series,
ShowAudience.Pg13, ShowAudience.Pg13,
BlockMinutes: 90, BlockMinutes: 60,
UnitsPerBlock: 3 UnitsPerBlock: 2,
Rotate: true
), ),
new( new(
At(18), At(18),
@@ -281,8 +357,8 @@ public static class GridProfiles
"Вечерние ситкомы", "Вечерние ситкомы",
ShowKind.Series, ShowKind.Series,
ShowAudience.Pg13, ShowAudience.Pg13,
BlockMinutes: 120, BlockMinutes: 60,
UnitsPerBlock: 4 UnitsPerBlock: 2
), ),
new( new(
At(22), At(22),
@@ -315,8 +391,8 @@ public static class GridProfiles
"Дневные ситкомы", "Дневные ситкомы",
ShowKind.Series, ShowKind.Series,
ShowAudience.Pg13, ShowAudience.Pg13,
BlockMinutes: 120, BlockMinutes: 60,
UnitsPerBlock: 4 UnitsPerBlock: 2
), ),
new( new(
At(20), At(20),
@@ -366,7 +442,8 @@ public static class GridProfiles
DayBlock, DayBlock,
BlockMinutes: 240, BlockMinutes: 240,
Strategy: SlotStrategyType.RandomWithCooldown, Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 2 CooldownDays: 2,
Rotate: true
), ),
new( new(
At(18), At(18),
@@ -506,7 +583,8 @@ public static class GridProfiles
ShowKind.Series, ShowKind.Series,
ShowAudience.Pg, ShowAudience.Pg,
BlockMinutes: 90, BlockMinutes: 90,
UnitsPerBlock: 3 UnitsPerBlock: 3,
Rotate: true
), ),
new( new(
At(18), At(18),
@@ -0,0 +1,127 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Generate;
/// <summary>
/// Группы канала глазами раскладки: сколько эфира каждая даёт, какой она природы и насколько строгий
/// в ней контент. Отдельный тип, а не метод планировщика: те же факты нужны и запросу к ИИ, а считать
/// их дважды значит однажды разойтись — сетка собиралась бы по одним числам, а ИИ предлагал по другим.
/// </summary>
public sealed class GroupCatalog(
IAppDbContext dbContext,
DynamicGroupResolver dynamicResolver,
GroupElementResolver elementResolver
)
{
public async Task<List<GroupCandidate>> LoadAsync(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 genreId = await PrimaryGenreAsync(composition, cancellationToken);
if (Describe(group, composition, info, genreId) is { } candidate)
candidates.Add(candidate);
}
return candidates;
}
/// <summary>
/// Основной жанр группы — самый частый среди её шоу. Нужен, чтобы не ставить подряд две полосы
/// одного жанра и не выйти за потолок доли жанра из правил канала.
/// </summary>
private async Task<Guid?> PrimaryGenreAsync(
IReadOnlyList<ResolvedElement> composition,
CancellationToken cancellationToken
)
{
var showIds = composition
.Where(e => e.Kind == GroupElementKind.Show)
.Select(e => e.Id)
.ToList();
var collectionIds = composition
.Where(e => e.Kind == GroupElementKind.Collection)
.Select(e => e.Id)
.ToList();
if (collectionIds.Count > 0)
{
var parts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => i.ShowId)
.ToListAsync(cancellationToken);
showIds.AddRange(parts);
}
if (showIds.Count == 0)
return null;
var genres = await dbContext
.ShowGenres.AsNoTracking()
.Where(g => showIds.Contains(g.ShowId) && g.IsPrimary)
.Select(g => g.GenreId)
.ToListAsync(cancellationToken);
return genres.Count == 0
? null
: genres.GroupBy(id => id).OrderByDescending(g => g.Count()).First().Key;
}
/// <summary>
/// Сводит вычисленный состав к тому, что нужно раскладке. Группа без готовых единиц не годится
/// вовсе — слот по ней встал бы, а место закрыл фон.
/// </summary>
private static GroupCandidate? Describe(
Group group,
IReadOnlyList<ResolvedElement> composition,
IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo> info,
Guid? primaryGenreId
)
{
var resolved = composition
.Select(e => info.GetValueOrDefault((e.Kind, e.Id)))
.OfType<GroupElementInfo>()
.ToList();
var units = resolved.Sum(r => r.UnitCount);
if (units == 0)
return null;
// У коллекции своего типа нет: франшиза — это почти всегда полнометражки.
var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount);
var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration);
return new GroupCandidate(
group.Id,
group.Name,
composition.Count,
units,
resolved.Max(r => r.Audience),
seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single,
duration.TotalMinutes / units,
primaryGenreId
);
}
}
@@ -43,6 +43,7 @@ public sealed class PlanRun(
private int _dayMinutes; private int _dayMinutes;
private int _baselineMinutes; private int _baselineMinutes;
private Guid? _lastGenreId; private Guid? _lastGenreId;
private Guid? _lastGroupId;
private readonly List<PlanNote> _notes = []; private readonly List<PlanNote> _notes = [];
private readonly HashSet<string> _seen = []; private readonly HashSet<string> _seen = [];
@@ -73,6 +74,7 @@ public sealed class PlanRun(
_dayGenreMinutes = fromBaseline ? new Dictionary<Guid, int>(_baselineGenre) : []; _dayGenreMinutes = fromBaseline ? new Dictionary<Guid, int>(_baselineGenre) : [];
_dayMinutes = fromBaseline ? _baselineMinutes : 0; _dayMinutes = fromBaseline ? _baselineMinutes : 0;
_lastGenreId = null; _lastGenreId = null;
_lastGroupId = null;
} }
/// <summary> /// <summary>
@@ -100,6 +102,12 @@ public sealed class PlanRun(
public bool FollowsSameGenre(GroupCandidate group) => public bool FollowsSameGenre(GroupCandidate group) =>
group.PrimaryGenreId is { } genreId && _lastGenreId == genreId; group.PrimaryGenreId is { } genreId && _lastGenreId == genreId;
/// <summary>
/// Та же группа, что и в предыдущем слоте. Два блока подряд из одной группы читаются как один
/// длинный: в сетке это выглядит не ритмом канала, а нехваткой контента.
/// </summary>
public bool FollowsSameGroup(GroupCandidate group) => _lastGroupId == group.Id;
// ── Неделя ─────────────────────────────────────────────────────────── // ── Неделя ───────────────────────────────────────────────────────────
/// <summary>Единиц группы, ещё не разобранных под слоты этой недели (может уйти в минус).</summary> /// <summary>Единиц группы, ещё не разобранных под слоты этой недели (может уйти в минус).</summary>
@@ -116,6 +124,7 @@ public sealed class PlanRun(
_weekAirings[group.Id] = _weekAirings.GetValueOrDefault(group.Id) + airingsPerWeek; _weekAirings[group.Id] = _weekAirings.GetValueOrDefault(group.Id) + airingsPerWeek;
_dayUsed[group.Id] = _dayUsed.GetValueOrDefault(group.Id) + 1; _dayUsed[group.Id] = _dayUsed.GetValueOrDefault(group.Id) + 1;
_dayMinutes += minutes; _dayMinutes += minutes;
_lastGroupId = group.Id;
if (group.PrimaryGenreId is not { } genreId) if (group.PrimaryGenreId is not { } genreId)
return; return;
@@ -18,7 +18,9 @@ public sealed record JunctionConditions(
/// <summary>Только в это окно суток канала (null — в любое время).</summary> /// <summary>Только в это окно суток канала (null — в любое время).</summary>
JunctionTimeWindow? TimeWindow = null, JunctionTimeWindow? TimeWindow = null,
/// <summary>Вероятность показа в процентах; 100 — всегда.</summary> /// <summary>Вероятность показа в процентах; 100 — всегда.</summary>
int Chance = 100 int Chance = 100,
/// <summary>Ставить только в пределах ±N минут от круглого часа (0 — без привязки).</summary>
int NearHourMinutes = 0
) )
{ {
private static readonly JsonSerializerOptions Options = new() private static readonly JsonSerializerOptions Options = new()
@@ -84,5 +84,9 @@ public sealed class UpdateJunctionElementCommandValidator
RuleFor(x => x.Input.Conditions!.Chance) RuleFor(x => x.Input.Conditions!.Chance)
.InclusiveBetween(0, 100) .InclusiveBetween(0, 100)
.When(x => x.Input.Conditions is not null); .When(x => x.Input.Conditions is not null);
// Допуск больше получаса перестаёт быть привязкой к часу: он покрывает час целиком.
RuleFor(x => x.Input.Conditions!.NearHourMinutes)
.InclusiveBetween(0, 30)
.When(x => x.Input.Conditions is not null);
} }
} }
@@ -0,0 +1,35 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Transfer;
/// <summary>
/// Собирает текст запроса к ИИ: «вот канал, вот что у меня в библиотеке, вот на какие каналы
/// я равняюсь — верни сетку в этом формате». Запрос не отправляется никуда: админ копирует его
/// в ту модель, которой пользуется, и приносит ответ обратно кнопкой импорта. Ключей внешних
/// сервисов проект не хранит, и заводить их ради одной кнопки не нужно.
/// </summary>
/// <param name="References">Каналы-референсы: «как у 2×2», «как у Paramount Comedy».</param>
/// <param name="Notes">Пожелания админа своими словами — попадают в запрос как есть.</param>
public sealed record BuildGridPromptQuery(
Guid ChannelId,
IReadOnlyList<string> References,
string? Notes
) : IQuery<Result<GridPromptDto>>;
/// <summary>
/// Готовый запрос и то, из чего он собран: админ должен видеть, что модель получит библиотеку
/// целиком, а не первые десять шоу.
/// </summary>
public sealed record GridPromptDto(string Prompt, int Groups, int Shows, int Characters);
public sealed class BuildGridPromptQueryValidator : AbstractValidator<BuildGridPromptQuery>
{
public BuildGridPromptQueryValidator()
{
RuleFor(x => x.References.Count).LessThanOrEqualTo(16);
RuleForEach(x => x.References).NotEmpty().MaximumLength(128);
RuleFor(x => x.Notes).MaximumLength(4000);
}
}
@@ -0,0 +1,264 @@
using System.Globalization;
using System.Text;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Programming.Templates.Transfer;
public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCatalog catalog)
: IQueryHandler<BuildGridPromptQuery, Result<GridPromptDto>>
{
/// <summary>
/// Потолок на список шоу. Библиотека канала может быть в тысячах серий, а запрос читает модель
/// с ограниченным контекстом: группы для сетки важнее поимённого перечисления, поэтому список
/// обрезается, и об обрезке прямо написано в запросе.
/// </summary>
private const int ShowLimit = 150;
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
public async Task<Result<GridPromptDto>> Handle(
BuildGridPromptQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<GridPromptDto>(ChannelErrors.NotFound);
var groups = await catalog.LoadAsync(cancellationToken);
var genres = await dbContext
.Genres.AsNoTracking()
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var shows = await LoadShowsAsync(cancellationToken);
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.OrderBy(j => j.Name)
.Select(j => j.Name)
.ToListAsync(cancellationToken);
var bumpers = await dbContext
.BumperTemplates.AsNoTracking()
.OrderBy(b => b.Name)
.Select(b => b.Name)
.ToListAsync(cancellationToken);
var text = new StringBuilder();
text.AppendLine(GridPromptText.Task).AppendLine();
text.AppendLine("## Канал");
text.AppendLine(
Culture,
$"- Название: {channel.Name}\n"
+ $"- Вещательные сутки начинаются в {channel.DayStartTime:HH\\:mm} (dayStart)\n"
+ $"- Время канала: UTC{(channel.UtcOffsetMinutes < 0 ? "-" : "+")}{Math.Abs(channel.UtcOffsetMinutes) / 60}:{Math.Abs(channel.UtcOffsetMinutes) % 60:00}"
);
AppendReferences(text, query);
AppendGroups(text, groups, genres);
AppendShows(text, shows, genres);
AppendNames(
text,
"Стыки",
junctions,
"стыков ещё нет — поля junctionAfter/junctionBetween оставь пустыми"
);
AppendNames(text, "Заставки", bumpers, "заставок ещё нет");
text.AppendLine().AppendLine(GridPromptText.Rules);
text.AppendLine().AppendLine(GridPromptText.Schema);
text.AppendLine().AppendLine(GridPromptText.Answer);
var prompt = text.ToString();
return Result.Success(new GridPromptDto(prompt, groups.Count, shows.Count, prompt.Length));
}
private static void AppendReferences(StringBuilder text, BuildGridPromptQuery query)
{
var references = query.References.Where(r => !string.IsNullOrWhiteSpace(r)).ToList();
if (references.Count > 0)
{
text.AppendLine().AppendLine("## Референсы");
text.AppendLine(
"Равняйся на ритм этих каналов — длину блоков, что стоит в прайме, чем закрыта ночь:"
);
foreach (var reference in references)
text.AppendLine(Culture, $"- {reference.Trim()}");
}
if (string.IsNullOrWhiteSpace(query.Notes))
return;
text.AppendLine().AppendLine("## Пожелания");
text.AppendLine(query.Notes.Trim());
}
private static void AppendGroups(
StringBuilder text,
IReadOnlyList<GroupCandidate> groups,
IReadOnlyDictionary<Guid, string> genres
)
{
text.AppendLine().AppendLine("## Доступные группы");
if (groups.Count == 0)
{
text.AppendLine(
"Готовых групп нет — сетку по ним собрать нельзя. Сначала собери группы в библиотеке."
);
return;
}
text.AppendLine(
"Имя группы в слотах должно совпадать с этим списком дословно. «Единиц» — сколько "
+ "выходов группа даёт без повторов, «средняя» — длительность одной единицы."
);
text.AppendLine();
text.AppendLine("| Группа | Тип | Единиц | Средняя | Рейтинг | Жанр |");
text.AppendLine("|---|---|---|---|---|---|");
foreach (var group in groups.OrderByDescending(g => g.UnitCount))
{
var genre = group.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null;
text.AppendLine(
Culture,
$"| {group.Name} | {Kind(group.DominantKind)} | {group.UnitCount} | "
+ $"{(int)Math.Round(group.AverageUnitMinutes)} мин | "
+ $"{group.Strictest?.ToString() ?? ""} | {genre ?? ""} |"
);
}
}
private static void AppendShows(
StringBuilder text,
IReadOnlyList<ShowFact> shows,
IReadOnlyDictionary<Guid, string> genres
)
{
text.AppendLine().AppendLine("## Библиотека");
if (shows.Count == 0)
{
text.AppendLine("Библиотека пуста.");
return;
}
text.AppendLine(
$"Всего готового к эфиру: {shows.Count}. Это справка о том, чем канал располагает; "
+ "слоты всё равно ссылаются на группы, а не на шоу."
);
text.AppendLine();
text.AppendLine("| Шоу | Тип | Год | Единиц | Средняя | Рейтинг | Жанр |");
text.AppendLine("|---|---|---|---|---|---|---|");
foreach (var show in shows.Take(ShowLimit))
{
var genre = show.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null;
text.AppendLine(
Culture,
$"| {show.Name} | {Kind(show.Kind)} | {show.Year?.ToString(Culture) ?? ""} | "
+ $"{show.Units} | {show.AverageMinutes} мин | "
+ $"{show.Audience?.ToString() ?? ""} | {genre ?? ""} |"
);
}
if (shows.Count > ShowLimit)
text.AppendLine(
Culture,
$"\n…и ещё {shows.Count - ShowLimit} — список обрезан, полный состав виден по группам выше."
);
}
private static void AppendNames(
StringBuilder text,
string title,
IReadOnlyList<string> names,
string empty
)
{
text.AppendLine().AppendLine(Culture, $"## {title}");
text.AppendLine(names.Count == 0 ? empty : string.Join(", ", names));
}
private static string Kind(ShowKind kind) =>
kind switch
{
ShowKind.Series => "сериал",
ShowKind.Interstitial => "ролик",
_ => "полный метр",
};
/// <summary>
/// Шоу, у которых есть что показывать. Считается по готовым медиа: шоу без обработанного файла
/// в эфир не пойдёт, и предлагать его модели значит получить сетку под несуществующий контент.
/// </summary>
private async Task<List<ShowFact>> LoadShowsAsync(CancellationToken cancellationToken)
{
var shows = await dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.ToListAsync(cancellationToken);
var assetIds = shows
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
.Distinct()
.ToList();
var ready = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
.ToDictionaryAsync(a => a.Id, a => a.Duration ?? TimeSpan.Zero, cancellationToken);
var primaryGenres = await dbContext
.ShowGenres.AsNoTracking()
.Where(g => g.IsPrimary)
.ToDictionaryAsync(g => g.ShowId, g => g.GenreId, cancellationToken);
var facts = new List<ShowFact>();
foreach (var show in shows)
{
var durations = show
.Episodes.Select(e =>
ready.TryGetValue(e.MediaAssetId, out var d) ? d : (TimeSpan?)null
)
.OfType<TimeSpan>()
.ToList();
if (durations.Count == 0)
continue;
facts.Add(
new ShowFact(
show.Name,
show.Kind,
show.Year,
show.Audience,
durations.Count,
(int)Math.Round(durations.Average(d => d.TotalMinutes)),
primaryGenres.TryGetValue(show.Id, out var genreId) ? genreId : null
)
);
}
return
[
.. facts
.OrderByDescending(s => s.Units)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
];
}
private sealed record ShowFact(
string Name,
ShowKind Kind,
int? Year,
ShowAudience? Audience,
int Units,
int AverageMinutes,
Guid? PrimaryGenreId
);
}
@@ -0,0 +1,10 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Transfer;
/// <summary>
/// Выгрузка сетки канала одним файлом: перенос на другой канал, бэкап перед экспериментом
/// и образец для ИИ — один и тот же формат на все три случая.
/// </summary>
public sealed record ExportGridQuery(Guid ChannelId) : IQuery<Result<GridConfig>>;
@@ -0,0 +1,100 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Transfer;
public sealed class ExportGridQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ExportGridQuery, Result<GridConfig>>
{
public async Task<Result<GridConfig>> Handle(
ExportGridQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<GridConfig>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == query.ChannelId, cancellationToken);
if (template is null)
return Result.Failure<GridConfig>(ChannelErrors.TemplateNotFound);
var groups = await dbContext
.Groups.AsNoTracking()
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.ToDictionaryAsync(j => j.Id, j => j.Name, cancellationToken);
var layers = template
.Layers.OrderBy(l => l.Priority)
.Select(layer => new GridConfigLayer(
layer.Name,
layer.Priority,
[
.. layer
.Slots.OrderBy(s => s.Weekday ?? -1)
.ThenBy(s => s.TargetStart)
.Select(slot => ToConfig(slot, groups, junctions)),
],
layer.IsEnabled,
layer.IsBackground,
LayerApplicability.FromJson(layer.ApplicabilityJson)
))
.ToList();
return Result.Success(
new GridConfig(
layers,
Channel: new GridConfigChannel(
channel.Name,
channel.DayStartTime,
channel.UtcOffsetMinutes
),
Rules: PlanningRules.FromJson(template.RulesJson),
FallbackGroup: Name(template.FallbackGroupId, groups),
DefaultJunction: Name(template.DefaultJunctionId, junctions)
)
);
}
private static GridConfigSlot ToConfig(
Slot slot,
IReadOnlyDictionary<Guid, string> groups,
IReadOnlyDictionary<Guid, string> junctions
) =>
new(
slot.Title,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.SlotKind,
slot.Weekday,
Name(slot.GroupId, groups),
SlotStrategy.FromJson(slot.StrategyJson),
RepeatSource.FromJson(slot.RepeatSourceJson),
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
Name(slot.JunctionBetweenId, junctions),
Name(slot.JunctionAfterId, junctions)
);
/// <summary>Имя по ссылке. Пропавшая ссылка выгружается пустой — файл важнее её сохранности.</summary>
private static string? Name(Guid? id, IReadOnlyDictionary<Guid, string> names) =>
id is { } value ? names.GetValueOrDefault(value) : null;
}
@@ -0,0 +1,80 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Transfer;
/// <summary>
/// Слот в формате обмена. Группы и стыки — **по именам**, а не по идентификаторам: файл переносится
/// между каналами и установками, где тех же GUID нет, а сетку для импорта пишет в том числе ИИ,
/// который идентификаторы может только выдумать.
/// </summary>
public sealed record GridConfigSlot(
string Title,
TimeOnly Start,
int DurationMinutes,
Daypart Daypart = Daypart.Day,
SlotKind Kind = SlotKind.Content,
/// <summary>0=Вс..6=Сб; null — слот идёт каждый день.</summary>
int? Weekday = null,
/// <summary>Имя группы — обязательно для слотов вида Content.</summary>
string? Group = null,
SlotStrategy? Strategy = null,
RepeatSource? Repeat = null,
SlotBlockMode BlockMode = SlotBlockMode.FillSlot,
int BlockValue = 1,
OverflowPolicy Overflow = OverflowPolicy.ContinueNext,
bool IsAnchor = false,
int MaxDriftMinutes = Slot.DefaultMaxDriftMinutes,
int? SnapToMinutes = null,
/// <summary>Имя стыка между единицами блока.</summary>
string? JunctionBetween = null,
/// <summary>Имя стыка в конце блока; null — стык шаблона по умолчанию.</summary>
string? JunctionAfter = null
);
/// <summary>Слой сетки в формате обмена.</summary>
public sealed record GridConfigLayer(
string Name,
int Priority,
IReadOnlyList<GridConfigSlot> Slots,
bool IsEnabled = true,
/// <summary>Фоновый слой один и создаётся вместе с шаблоном — при импорте в него только пишутся слоты.</summary>
bool IsBackground = false,
LayerApplicability? Applicability = null
);
/// <summary>Параметры канала — переносятся справочно: импорт их не применяет.</summary>
public sealed record GridConfigChannel(string Name, TimeOnly DayStart, int UtcOffsetMinutes);
/// <summary>
/// Конфигурация сетки канала одним файлом: слои, слоты, правила отбора и ссылки на группы и стыки
/// по именам. Формат один и тот же на выгрузку, загрузку и на то, что просят сгенерировать ИИ, —
/// иначе схема в запросе однажды разойдётся с тем, что читает импорт.
/// </summary>
public sealed record GridConfig(
IReadOnlyList<GridConfigLayer> Layers,
string Format = GridConfig.CurrentFormat,
int Version = GridConfig.CurrentVersion,
GridConfigChannel? Channel = null,
PlanningRules? Rules = null,
/// <summary>Имя аварийной группы — ею закрываются паузы между слотами.</summary>
string? FallbackGroup = null,
/// <summary>Имя стыка по умолчанию.</summary>
string? DefaultJunction = null
)
{
public const string CurrentFormat = "telewave.grid";
public const int CurrentVersion = 1;
public static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
WriteIndented = true,
};
public string ToJson() => JsonSerializer.Serialize(this, Options);
}
@@ -0,0 +1,88 @@
namespace TeleWave.Application.Programming.Templates.Transfer;
/// <summary>
/// Постоянная часть запроса к ИИ: задача, правила и схема ответа. Схема описывает ровно то, что
/// читает <see cref="ImportGridCommandHandler"/>, — держать её рядом с форматом, а не в шаблоне
/// на фронте, значит не разойтись с импортом при первой же правке полей.
/// </summary>
public static class GridPromptText
{
public const string Task = """
Ты программный директор телеканала. Собери недельную сетку вещания и верни её одним
JSON-документом описанного ниже формата.
""";
public const string Rules = """
Правила, по которым сетку потом разложит планировщик:
- Сутки вещательные, а не календарные: они начинаются со времени dayStart и заканчиваются
им же следующего дня. Ночь после полуночи принадлежит предыдущему дню.
- Слот это место в сетке, а не конкретная серия: что именно выйдет, выберет планировщик
из указанной группы. Поэтому группа задаётся именем, и имя должно быть точно из списка
доступных ниже. Придумывать новые группы, шоу и стыки нельзя.
- Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой
с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»).
- weekday: 0=воскресенье .. 6=суббота, либо null слот идёт каждый день. Предпочитай
«каждый день» там, где содержание не меняется: сетка должна читаться.
- Полосы суток должны покрывать их целиком, без дыр: незакрытое время в эфире займёт
аварийный фон, и это видно зрителю.
- Длина слота из длительности контента группы, округлённая до четверти часа: программа
передач состоит из круглых времён, а разницу занимает рекламный шов.
- isAnchor=true ставится там, где время объявлено зрителю (начало прайма). Не ставь якорь
на каждый слот подряд.
- kind: "Content" обычный слот с группой; "Repeat" повтор прошлого эфира (нужен блок
repeat: daysAgo, time, durationMinutes); "SignOff" конец вещания.
- blockMode: "Count" blockValue единиц подряд (серий, фильмов); "FillSlot" сколько
влезет; "Duration" blockValue минут.
- strategy.type: "Sequential" по порядку (для сериалов), "Random" или
"RandomWithCooldown" с cooldownDays для фильмов и разнобоя.
- Рейтинг соблюдай: строгое (R, Nc17) только в ночных полосах, детское утром и днём.
""";
public const string Schema = """
Схема ответа (комментарии пояснения, в JSON их быть не должно):
{
"format": "telewave.grid",
"version": 1,
"rules": { // необязательный блок правил канала
"maxGenreSharePercent": 40,
"maxBreakMinutesPerHour": 12
},
"fallbackGroup": "имя группы", // чем закрывать паузы
"defaultJunction": "имя стыка", // стык между блоками по умолчанию
"layers": [
{
"name": "Основная сетка",
"priority": 10,
"isEnabled": true,
"applicability": { // необязательно: когда слой действует
"annualRanges": [{ "fromMonth": 12, "fromDay": 25, "toMonth": 1, "toDay": 8 }]
},
"slots": [
{
"title": "Утренние мультфильмы",
"start": "06:00",
"durationMinutes": 120,
"daypart": "Morning", // Morning | Day | Prime | Night
"kind": "Content", // Content | Repeat | SignOff
"weekday": null, // 0..6 либо null
"group": "Мультфильмы", // точное имя из списка доступных
"blockMode": "Count", // Count | FillSlot | Duration
"blockValue": 4,
"strategy": { "type": "Sequential", "restartOnEnd": true },
"isAnchor": false,
"snapToMinutes": 15,
"junctionAfter": "имя стыка" // необязательно
}
]
}
]
}
""";
public const string Answer = """
Верни только JSON, без пояснений вокруг него и без markdown-ограждения. Этот файл будет
загружен кнопкой импорта как есть.
""";
}
@@ -0,0 +1,45 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Transfer;
/// <summary>
/// Загрузка сетки из файла. <paramref name="Replace"/> — снести существующие слоты и построить
/// заново; иначе слои и слоты добавляются к тому, что есть.
/// </summary>
public sealed record ImportGridCommand(Guid ChannelId, GridConfig Config, bool Replace)
: ICommand<Result<GridImportResultDto>>;
/// <summary>
/// Что получилось. Пропущенные слоты — не ошибка импорта: файл мог прийти от ИИ или с другой
/// установки, где были свои группы, и валить весь импорт из-за одной незнакомой ссылки значит
/// заставить админа чинить JSON руками.
/// </summary>
public sealed record GridImportResultDto(
int Layers,
int Slots,
int Skipped,
int Removed,
IReadOnlyList<string> Warnings
);
public sealed class ImportGridCommandValidator : AbstractValidator<ImportGridCommand>
{
public ImportGridCommandValidator()
{
RuleFor(x => x.Config.Format)
.Equal(GridConfig.CurrentFormat)
.WithMessage($"Ожидается формат «{GridConfig.CurrentFormat}».");
RuleFor(x => x.Config.Version).InclusiveBetween(1, GridConfig.CurrentVersion);
RuleFor(x => x.Config.Layers).NotEmpty();
// Потолок на объём файла: недельная сетка — это десятки слотов, а не десятки тысяч.
RuleFor(x => x.Config.Layers.Count).LessThanOrEqualTo(64);
RuleForEach(x => x.Config.Layers)
.ChildRules(layer =>
{
layer.RuleFor(l => l.Name).NotEmpty().MaximumLength(128);
layer.RuleFor(l => l.Slots.Count).LessThanOrEqualTo(2048);
});
}
}
@@ -0,0 +1,239 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Transfer;
/// <summary>
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
///
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
/// </summary>
public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter writer)
: ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
{
public async Task<Result<GridImportResultDto>> Handle(
ImportGridCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.ChannelId, cancellationToken);
if (template is null)
return Result.Failure<GridImportResultDto>(ChannelErrors.TemplateNotFound);
var groups = await NamesAsync(
dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)),
cancellationToken
);
var junctions = await NamesAsync(
dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)),
cancellationToken
);
var warnings = new List<string>();
var removed = command.Replace ? Clear(template) : 0;
var created = 0;
var skipped = 0;
foreach (var configLayer in command.Config.Layers)
{
var layer = EnsureLayer(template, configLayer);
foreach (var configSlot in configLayer.Slots)
{
var input = ToInput(configSlot, groups, junctions, warnings);
if (input is null)
{
skipped++;
continue;
}
var applied = await writer.ApplyAsync(layer, null, input, cancellationToken);
if (applied.IsSuccess)
{
created++;
continue;
}
skipped++;
warnings.Add(
$"Слот «{configSlot.Title}» ({configSlot.Start:HH\\:mm}): {applied.Error.Message}"
);
}
}
Apply(template, command.Config, groups, junctions, warnings);
template.MarkChanged();
return Result.Success(
new GridImportResultDto(
command.Config.Layers.Count,
created,
skipped,
removed,
warnings
)
);
}
/// <summary>Снимает все слоты шаблона и возвращает их число — режим замены.</summary>
private static int Clear(ScheduleTemplate template)
{
var removed = 0;
foreach (var layer in template.Layers)
{
// Идентификаторы снимаем заранее: удаление правит ту самую коллекцию, по которой идём.
var slotIds = layer.Slots.Select(s => s.Id).ToList();
foreach (var slotId in slotIds)
layer.RemoveSlot(slotId);
removed += slotIds.Count;
}
return removed;
}
/// <summary>
/// Слой под слоты файла. Существующий берётся по имени: повторный импорт того же файла должен
/// давать ту же сетку, а не «Основная сетка (2)». Фоновый слой по имени не ищется — он один
/// и создаётся вместе с шаблоном.
/// </summary>
private static GridLayer EnsureLayer(ScheduleTemplate template, GridConfigLayer configLayer)
{
if (configLayer.IsBackground && template.Background is { } background)
return background;
var existing = template.Layers.FirstOrDefault(l =>
!l.IsBackground
&& string.Equals(l.Name, configLayer.Name, StringComparison.OrdinalIgnoreCase)
);
if (existing is not null)
return existing;
var layer = template.AddLayer(configLayer.Name, configLayer.Priority);
layer.Update(
configLayer.Name,
configLayer.Priority,
configLayer.Applicability?.ToJson(),
configLayer.IsEnabled
);
return layer;
}
/// <summary>Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале.</summary>
private static void Apply(
ScheduleTemplate template,
GridConfig config,
IReadOnlyDictionary<string, Guid> groups,
IReadOnlyDictionary<string, Guid> junctions,
List<string> warnings
)
{
if (config.Rules is { } rules)
template.SetRules(rules.ToJson());
if (config.FallbackGroup is { Length: > 0 } fallback)
{
if (Resolve(fallback, groups, "Группа", warnings) is { } groupId)
template.SetFallbackGroup(groupId);
}
if (config.DefaultJunction is { Length: > 0 } junction)
{
if (Resolve(junction, junctions, "Стык", warnings) is { } junctionId)
template.SetDefaultJunction(junctionId);
}
}
private static SlotInput? ToInput(
GridConfigSlot slot,
IReadOnlyDictionary<string, Guid> groups,
IReadOnlyDictionary<string, Guid> junctions,
List<string> warnings
)
{
if (string.IsNullOrWhiteSpace(slot.Title) || slot.DurationMinutes <= 0)
{
warnings.Add($"Слот «{slot.Title}»: нет названия или длительности.");
return null;
}
Guid? groupId = null;
if (slot.Group is { Length: > 0 } groupName)
{
groupId = Resolve(groupName, groups, "Группа", warnings);
if (groupId is null)
return null;
}
else if (slot.Kind == SlotKind.Content)
{
warnings.Add($"Слот «{slot.Title}»: для содержательного слота нужна группа.");
return null;
}
return new SlotInput(
slot.Title,
slot.Weekday,
slot.Start,
slot.DurationMinutes,
slot.Daypart,
slot.Kind,
groupId,
slot.Strategy,
slot.Repeat,
slot.BlockMode,
slot.BlockValue,
slot.Overflow,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetween is { Length: > 0 } between
? Resolve(between, junctions, "Стык", warnings)
: null,
slot.JunctionAfter is { Length: > 0 } after
? Resolve(after, junctions, "Стык", warnings)
: null
);
}
private static Guid? Resolve(
string name,
IReadOnlyDictionary<string, Guid> known,
string subject,
List<string> warnings
)
{
if (known.TryGetValue(name.Trim(), out var id))
return id;
var warning = $"{subject} «{name}» не найдена в библиотеке.";
if (!warnings.Contains(warning, StringComparer.Ordinal))
warnings.Add(warning);
return null;
}
/// <summary>Справочник «имя → идентификатор». Одинаковые имена схлопываются в первое найденное.</summary>
private static async Task<Dictionary<string, Guid>> NamesAsync(
IQueryable<NamedRef> source,
CancellationToken cancellationToken
)
{
var items = await source.ToListAsync(cancellationToken);
var map = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items)
map.TryAdd(item.Name.Trim(), item.Id);
return map;
}
private sealed record NamedRef(Guid Id, string Name);
}
@@ -155,11 +155,37 @@ public static class JunctionFiller
return false; return false;
} }
if (!NearHour(element, cursor, placement.ChannelOffset))
return false;
// Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации, // Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации,
// поэтому пересборка хвоста не перетасовывает врезки на каждое применение. // поэтому пересборка хвоста не перетасовывает врезки на каждое применение.
return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance); return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance);
} }
/// <summary>
/// Рядом ли момент с круглым часом. Сигнал точного времени — «пик-пик-пик» — тем и ценен, что
/// звучит в :00, поэтому врезка с допуском ставится только в его окрестности, а не «когда-нибудь
/// в этом часе».
/// </summary>
private static bool NearHour(
PlanningJunctionElement element,
DateTimeOffset cursor,
TimeSpan channelOffset
)
{
if (element.NearHourMinutes <= 0)
return true;
var time = cursor.ToOffset(channelOffset).TimeOfDay;
var intoHour = time - TimeSpan.FromHours((int)time.TotalHours);
// Допуск двусторонний: до часа считаем по остатку, после — по прошедшему.
var distance = TimeSpan.FromMinutes(
Math.Min(intoHour.TotalMinutes, 60 - intoHour.TotalMinutes)
);
return distance <= TimeSpan.FromMinutes(element.NearHourMinutes);
}
/// <summary> /// <summary>
/// Схлопывает развилки: из врезок с одной меткой остаётся одна, выбранная по весам. Позиция /// Схлопывает развилки: из врезок с одной меткой остаётся одна, выбранная по весам. Позиция
/// развилки в цепочке — позиция её первого участника. /// развилки в цепочке — позиция её первого участника.
@@ -111,6 +111,8 @@ public sealed record PlanningJunctionElement(
int Chance = 100, int Chance = 100,
/// <summary>Окно времени суток, вне которого врезка не ставится (null — всегда).</summary> /// <summary>Окно времени суток, вне которого врезка не ставится (null — всегда).</summary>
PlanningTimeWindow? TimeWindow = null, PlanningTimeWindow? TimeWindow = null,
/// <summary>Допуск от круглого часа в минутах; 0 — врезка не привязана к часу.</summary>
int NearHourMinutes = 0,
/// <summary>Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам.</summary> /// <summary>Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам.</summary>
string? ChoiceKey = null, string? ChoiceKey = null,
int ChoiceWeight = 1, int ChoiceWeight = 1,
@@ -149,12 +151,32 @@ public sealed record PlanningInput(
/// короче одной серии, добор до якоря, пустой слот-повтор. Отдельным списком, потому что /// короче одной серии, добор до якоря, пустой слот-повтор. Отдельным списком, потому что
/// в основном перекрытых слотов нет — там побеждает слой с большим приоритетом. /// в основном перекрытых слотов нет — там побеждает слой с большим приоритетом.
/// </summary> /// </summary>
IReadOnlyList<PlanningSlot> BackgroundSlots = null! IReadOnlyList<PlanningSlot> BackgroundSlots = null!,
/// <summary>
/// Чем добивается шов до круглой отметки и до якоря: ролики и анонсы стыка по умолчанию.
/// На настоящем ТВ остаток до 20:00 занимает реклама, а не ещё одна серия.
/// </summary>
IReadOnlyList<PadUnit> PadUnits = null!,
/// <summary>
/// Потолок одного добора рекламой. Шов в десять минут — это блок роликов, а двухчасовая дыра —
/// уже эфирное время, и заливать её рекламой нельзя.
/// </summary>
TimeSpan MaxPadDuration = default
) )
{ {
public IReadOnlyList<PlanningSlot> BackgroundSlots { get; init; } = BackgroundSlots ?? []; public IReadOnlyList<PlanningSlot> BackgroundSlots { get; init; } = BackgroundSlots ?? [];
public IReadOnlyList<PadUnit> PadUnits { get; init; } = PadUnits ?? [];
public TimeSpan MaxPadDuration { get; init; } =
MaxPadDuration > TimeSpan.Zero ? MaxPadDuration : DefaultMaxPad;
/// <summary>Сколько рекламы подряд допустимо в шве, если стык не задал свой потолок.</summary>
public static readonly TimeSpan DefaultMaxPad = TimeSpan.FromMinutes(10);
} }
/// <summary>Единица добора шва: ролик и чем он значится в ленте — рекламой или анонсом.</summary>
public sealed record PadUnit(PlanningUnit Unit, PlannedItemKind Kind);
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary> /// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
public sealed record PlannedItem( public sealed record PlannedItem(
Guid MediaAssetId, Guid MediaAssetId,
@@ -42,8 +42,12 @@ public static class SchedulePlanner
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary> /// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
public Guid? PreviousShowId { get; set; } public Guid? PreviousShowId { get; set; }
/// <summary>С какой единицы аварийного запаса продолжать — общий на прогон, см. NextFallbackUnit.</summary> /// <summary>Аварийный запас: кольцевой перебор, общий на прогон.</summary>
public int FallbackCursor { get; set; } public UnitRotation<PlanningUnit> Fallback { get; } =
new(input.FallbackUnits, unit => unit.Duration);
/// <summary>Ролики и анонсы для добора шва — тоже по кругу, чтобы не повторяться.</summary>
public UnitRotation<PadUnit> Pad { get; } = new(input.PadUnits, pad => pad.Unit.Duration);
/// <summary>Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса.</summary> /// <summary>Фоновый слой: чем закрываются паузы до того, как дело дойдёт до аварийного запаса.</summary>
public BackgroundFiller Background { get; } = new(input.BackgroundSlots, random); public BackgroundFiller Background { get; } = new(input.BackgroundSlots, random);
@@ -117,7 +121,7 @@ public static class SchedulePlanner
{ {
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно, // До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени. // для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
cursor = FillGap(cursor, slot.TargetStartUtc, run, slot.SlotId); cursor = FillGap(cursor, slot.TargetStartUtc, run, slot.SlotId, padFirst: true);
} }
else if (slot.SnapToMinutes is { } snap && snap > 0) else if (slot.SnapToMinutes is { } snap && snap > 0)
{ {
@@ -126,7 +130,7 @@ public static class SchedulePlanner
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap)); var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes)) if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
{ {
var afterFill = FillGap(cursor, target, run, slot.SlotId); var afterFill = FillGap(cursor, target, run, slot.SlotId, padFirst: true);
snapped = afterFill > cursor; snapped = afterFill > cursor;
cursor = afterFill; cursor = afterFill;
} }
@@ -392,11 +396,63 @@ public static class SchedulePlanner
/// поломкой и остаётся последней ступенью. /// поломкой и остаётся последней ступенью.
/// </summary> /// </summary>
private static DateTimeOffset FillGap( private static DateTimeOffset FillGap(
DateTimeOffset from,
DateTimeOffset until,
PlanningRun run,
Guid? slotId,
bool padFirst = false
)
{
// Шов до круглой отметки и до якоря по замыслу занимает реклама: «ещё одна серия вместо
// рекламы» — первое, что выдаёт в эфире плейлист вместо телеканала.
var cursor = padFirst ? FillWithPad(from, until, run, slotId) : from;
cursor = FillWithBackground(cursor, until, run);
// Остаток, куда программа уже не влезает, — снова шов, и он тоже рекламный.
cursor = FillWithPad(cursor, until, run, slotId);
return FillWithFallback(cursor, until, run, slotId);
}
/// <summary>Ставит ролики и анонсы, пока влезают и пока не исчерпан потолок одного добора.</summary>
private static DateTimeOffset FillWithPad(
DateTimeOffset from, DateTimeOffset from,
DateTimeOffset until, DateTimeOffset until,
PlanningRun run, PlanningRun run,
Guid? slotId Guid? slotId
) )
{
var limit = Min(until, from + run.Input.MaxPadDuration);
var cursor = from;
var guard = 0;
while (cursor < limit && guard++ < IterationBackstop)
{
if (run.Pad.Next(limit - cursor) is not { } pad)
break;
run.Items.Add(
new PlannedItem(
pad.Unit.MediaAssetId,
cursor,
cursor + pad.Unit.Duration,
null,
null,
slotId,
pad.Kind
)
);
cursor += pad.Unit.Duration;
}
return cursor;
}
/// <summary>Закрывает паузу программами фонового слоя — настоящим контентом своего времени суток.</summary>
private static DateTimeOffset FillWithBackground(
DateTimeOffset from,
DateTimeOffset until,
PlanningRun run
)
{ {
var cursor = from; var cursor = from;
var guard = 0; var guard = 0;
@@ -433,7 +489,7 @@ public static class SchedulePlanner
run.PreviousShowId = pick.Unit.ShowId; run.PreviousShowId = pick.Unit.ShowId;
} }
return FillWithFallback(cursor, until, run, slotId); return cursor;
} }
/// <summary> /// <summary>
@@ -450,15 +506,12 @@ public static class SchedulePlanner
PlanTrace? trace = null PlanTrace? trace = null
) )
{ {
if (run.Input.FallbackUnits.Count == 0 || until <= from)
return from;
var cursor = from; var cursor = from;
var guard = 0; var guard = 0;
while (cursor < until && guard++ < IterationBackstop) while (cursor < until && guard++ < IterationBackstop)
{ {
if (NextFallbackUnit(run, until - cursor) is not { } unit) if (run.Fallback.Next(until - cursor) is not { } unit)
break; break;
run.Items.Add( run.Items.Add(
@@ -466,8 +519,8 @@ public static class SchedulePlanner
unit.MediaAssetId, unit.MediaAssetId,
cursor, cursor,
cursor + unit.Duration, cursor + unit.Duration,
// Фон — это тоже программы из группы, и в эфирной ленте они обязаны быть // Аварийная группа — это тоже программы, и в ленте они обязаны быть подписаны:
// подписаны: без шоу зритель и админ видят на их месте прочерк. // без шоу зритель и админ видят на их месте прочерк. Филлер канала шоу не имеет.
unit.ShowId == Guid.Empty unit.ShowId == Guid.Empty
? null ? null
: unit.ShowId, : unit.ShowId,
@@ -483,33 +536,6 @@ public static class SchedulePlanner
return cursor; return cursor;
} }
/// <summary>
/// Следующая единица фона под остаток <paramref name="available"/>. Перебор продолжается с того
/// места, где остановился прошлый добор: иначе каждая пауза начиналась бы с одного и того же
/// ролика, и ночь выглядела бы одним зациклённым куском.
///
/// Если очередная единица не влезает, ищется первая подходящая среди остальных, а не бросается
/// весь добор: список отсортирован по возрастанию длительности, и «не влезла эта» не значит
/// «не влезет ничего» — иначе в ленте оставалась бы дыра при живом коротком контенте.
/// </summary>
private static PlanningUnit? NextFallbackUnit(PlanningRun run, TimeSpan available)
{
var units = run.Input.FallbackUnits;
for (var offset = 0; offset < units.Count; offset++)
{
var index = (run.FallbackCursor + offset) % units.Count;
var unit = units[index];
if (unit.Duration <= TimeSpan.Zero || unit.Duration > available)
continue;
run.FallbackCursor = (index + 1) % units.Count;
return unit;
}
return null;
}
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary> /// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
private static Guid? CollectionOf(PlanningElement element) => private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null; element.Kind == GroupElementKind.Collection ? element.ElementId : null;
@@ -0,0 +1,30 @@
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
/// Кольцевой перебор единиц с проверкой «влезает ли в остаток». Общий и для добора шва рекламой,
/// и для аварийного запаса: в обоих случаях нужно продолжать с места прошлого раза — иначе каждая
/// пауза начиналась бы с одного и того же ролика, — и уметь взять другую единицу, когда очередная
/// не влезла. «Не влезла эта» не значит «не влезет ничего»: в списке есть и короткие.
/// </summary>
public sealed class UnitRotation<T>(IReadOnlyList<T> items, Func<T, TimeSpan> duration)
where T : class
{
private int _cursor;
public T? Next(TimeSpan available)
{
for (var offset = 0; offset < items.Count; offset++)
{
var index = (_cursor + offset) % items.Count;
var item = items[index];
var length = duration(item);
if (length <= TimeSpan.Zero || length > available)
continue;
_cursor = (index + 1) % items.Count;
return item;
}
return null;
}
}
@@ -66,19 +66,28 @@ public class BumperPlaceholdersTests
{ {
var empty = new BumperContext("Канал", null, Moment); var empty = new BumperContext("Канал", null, Moment);
// Ни следующей программы, ни номера канала: строка схлопывается, осиротевший разделитель уходит. // Ни текущей, ни следующей программы: строка писалась ради них и исчезает целиком —
Assert.Equal("СЕЙЧАС", BumperPlaceholders.Resolve("СЕЙЧАС — {now.title}", empty)); // «СЕЙЧАС» в кадре без названия ничего не значит.
Assert.Equal("", BumperPlaceholders.Resolve("СЕЙЧАС — {now.title}", empty));
Assert.Equal("", BumperPlaceholders.Resolve("{next.title}", empty)); Assert.Equal("", BumperPlaceholders.Resolve("{next.title}", empty));
// А подставилось хоть что-то — строка остаётся, осиротевший хвост подчищается.
Assert.Equal("Канал", BumperPlaceholders.Resolve("{channel} {channel.number}", empty)); Assert.Equal("Канал", BumperPlaceholders.Resolve("{channel} {channel.number}", empty));
// А висящий предлог остаётся: отличить его от слова в чужом тексте нельзя. // Строка, где не подставился ни один плейсхолдер, исчезает целиком: «ДАЛЕЕ В» без времени —
Assert.Equal("ДАЛЕЕ В", BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time}", empty)); // это мусор в кадре, а не подпись.
Assert.Equal("", BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time}", empty));
// А постоянный текст без плейсхолдеров остаётся всегда.
Assert.Equal("СЕЙЧАС", BumperPlaceholders.Resolve("СЕЙЧАС", empty));
} }
[Fact] [Fact]
public void Resolve_UnknownTokenBecomesEmpty() public void Resolve_UnknownTokenLeavesNothing()
{ {
Assert.Equal("Далее", BumperPlaceholders.Resolve("Далее {next.tittle}", Full())); // Незнакомый плейсхолдер подставить нечем, а значит и строка пуста — как если бы данных
// не нашлось. Сохранить такую строку валидатор всё равно не даст.
Assert.Equal("", BumperPlaceholders.Resolve("Далее {next.tittle}", Full()));
Assert.Equal("", BumperPlaceholders.Resolve(" ", Full())); Assert.Equal("", BumperPlaceholders.Resolve(" ", Full()));
// Постоянный текст без плейсхолдеров остаётся как есть.
Assert.Equal("Далее", BumperPlaceholders.Resolve("Далее", Full()));
} }
[Fact] [Fact]
@@ -340,6 +340,74 @@ public class BumperResolverTests
); );
} }
[Fact]
public async Task Resolve_AnnouncesTonightsPrime()
{
var template = TemplateWith(Title("СЕГОДНЯ В {tonight.time} — {tonight.title}"));
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
// Канал в UTC: так время в анонсе читается без пересчёта в уме.
channel.UpdateTimeSettings(null, 0, Channel.DefaultDayStartTime);
var from = Show.Create("Симпсоны", ShowKind.Series);
var to = Show.Create("Гриффины", ShowKind.Series);
var feature = Show.Create("Терминатор", ShowKind.Single);
var grid = ScheduleTemplate.Create(channel.Id, "Сетка");
var day = grid.Background!.AddSlot("Дневной блок", new TimeOnly(14, 0), 240);
var prime = grid.Background!.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
prime.UpdateTiming(null, new TimeOnly(20, 0), 120, Daypart.Prime, false, 30, null);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.BumperTemplates.Add(template);
seed.Shows.AddRange(from, to, feature);
seed.ScheduleTemplates.Add(grid);
await seed.SaveChangesAsync(CancellationToken.None);
}
var items = Lente(template.Id, from.Id, to.Id, day.Id);
// Вечером тех же вещательных суток стоит фильм в прайме — его и должен назвать анонс.
items.Add(
new PlannedItem(
Guid.NewGuid(),
T0.AddHours(2),
T0.AddHours(4),
feature.Id,
0,
prime.Id,
PlannedItemKind.Program
)
);
await using var db = fixture.New();
var assets = await NewResolver(db).ResolveAsync(channel, items, CancellationToken.None);
await db.SaveChangesAsync(CancellationToken.None);
await using var verify = fixture.New();
var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assets[1]);
var line = Assert.Single(BumperRenderedText.FromJson(cache.RenderedLinesJson));
Assert.Equal("СЕГОДНЯ В 20:00 — Терминатор", line.Text);
}
[Fact]
public async Task Resolve_SkipsAnnouncementThatAlreadyStarted()
{
var template = TemplateWith(Title("СЕГОДНЯ В {tonight.time} — {tonight.title}"));
var (fixture, channel, from, to) = await SeedAsync(template);
// Прайма впереди нет — анонсировать нечего, и строка обязана схлопнуться, а не соврать.
await using var db = fixture.New();
var assets = await NewResolver(db)
.ResolveAsync(channel, Lente(template.Id, from.Id, to.Id), CancellationToken.None);
await db.SaveChangesAsync(CancellationToken.None);
await using var verify = fixture.New();
var cache = await verify.BumperAssets.SingleAsync(b => b.MediaAssetId == assets[1]);
Assert.Empty(BumperRenderedText.FromJson(cache.RenderedLinesJson));
}
[Fact] [Fact]
public async Task Resolve_IgnoresReservationsWithoutTemplate() public async Task Resolve_IgnoresReservationsWithoutTemplate()
{ {
@@ -107,7 +107,9 @@ public class GenerateGridTests
return (channel.Id, template); return (channel.Id, template);
} }
private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) => private static GridPlanner Planner(Common.Interfaces.IAppDbContext db) => new(db, Catalog(db));
private static GroupCatalog Catalog(Common.Interfaces.IAppDbContext db) =>
new(db, GroupServices.Dynamic(db), new GroupElementResolver(db)); new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));
private static async Task<GridPlan> PlanAsync( private static async Task<GridPlan> PlanAsync(
@@ -149,10 +151,15 @@ public class GenerateGridTests
Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes); Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
// Полосы без ротации свободны во все семь дней — они идут «каждый день», а не семью копиями. // Полосы без ротации свободны во все семь дней — они идут «каждый день», а не семью копиями.
Assert.All( Assert.All(
plan.Slots.Where(s => s.Start < new TimeOnly(20, 0)), plan.Slots.Where(s => s.Start < new TimeOnly(14, 0)),
slot => Assert.Null(slot.Weekday) slot => Assert.Null(slot.Weekday)
); );
// Прайм (с 20:00) чередуется по дням недели, поэтому у него дни проставлены явно. // Дневной блок и прайм чередуются по дням недели: иначе неделя выглядит одним повторяющимся
// днём. У них дни проставлены явно.
Assert.All(
plan.Slots.Where(s => s.Start >= new TimeOnly(14, 0) && s.Start < new TimeOnly(18, 0)),
slot => Assert.NotNull(slot.Weekday)
);
Assert.All( Assert.All(
plan.Slots.Where(s => s.Start == new TimeOnly(20, 0)), plan.Slots.Where(s => s.Start == new TimeOnly(20, 0)),
slot => Assert.NotNull(slot.Weekday) slot => Assert.NotNull(slot.Weekday)
@@ -163,6 +170,35 @@ public class GenerateGridTests
); );
} }
[Fact]
public async Task NeighbouringSlots_DoNotShareTheSameGroup()
{
// Две группы и целые сутки полос: раскладка обязана их чередовать, а не ставить одну
// и ту же встык — два блока подряд из одной группы читаются как один длинный.
var fixture = new TestDb();
var first = Playable("Первый сериал", ShowKind.Series, 60, 25);
var second = Playable("Второй сериал", ShowKind.Series, 60, 25);
var (channelId, _) = await SeedChannelAsync(
fixture,
[first.Show, second.Show],
[.. first.Assets, .. second.Assets],
[GroupOf("Первые", first.Show.Id), GroupOf("Вторые", second.Show.Id)]
);
var plan = await PlanAsync(fixture, channelId);
var byDay = plan
.Slots.Where(s => s.GroupId is not null)
.GroupBy(s => s.Weekday)
.Select(g => g.OrderBy(s => s.Start).ToList());
foreach (var day in byDay)
{
for (var i = 1; i < day.Count; i++)
Assert.NotEqual(day[i - 1].GroupId, day[i].GroupId);
}
}
[Fact] [Fact]
public async Task PrimeGetsFeature_AndMorningGetsSeries() public async Task PrimeGetsFeature_AndMorningGetsSeries()
{ {
@@ -322,10 +358,117 @@ public class GenerateGridTests
} }
[Fact] [Fact]
public async Task SlotLength_FollowsContent_NotProfileConstant() public async Task Rebuild_BuildsSeasonLayer_WithAnnualApplicability()
{ {
// Серии по 25 минут: двухчасовая полоса должна разложиться на блоки, кратные серии, var fixture = new TestDb();
// а не отдать десятки минут фону. var series = Playable("Сериал", ShowKind.Series, 40, 25);
var films = Films(8, 100);
var (channelId, _) = await SeedChannelAsync(
fixture,
[series.Show, .. films.Shows],
[.. series.Assets, .. films.Assets],
[
GroupOf("Сериалы", series.Show.Id),
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
]
);
await using (var db = fixture.New())
{
var result = await new GenerateGridCommandHandler(
db,
Planner(db),
new SlotWriter(db)
).Handle(
new GenerateGridCommand(
channelId,
GridProfileKind.Mixed,
GridGenerationMode.Rebuild
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var check = fixture.New();
var stored = await check
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstAsync(CancellationToken.None);
var season = GridProfiles.Get(GridProfileKind.Mixed).Season;
Assert.NotNull(season);
var layer = Assert.Single(stored.Layers, l => l.Name == season.Name);
// Праздничный слой обязан перекрывать и будни, и выходные — иначе 1 января пройдёт как
// обычное воскресенье.
var otherPriority = stored.Layers.Where(l => l.Id != layer.Id).Max(l => l.Priority);
Assert.True(layer.Priority > otherPriority);
Assert.NotEmpty(layer.Slots);
// Слой действует по ежегодному периоду: заводить его заново каждый декабрь не нужно.
var applicability = LayerApplicability.FromJson(layer.ApplicabilityJson);
Assert.NotNull(applicability);
Assert.Equal([season.Range], applicability.AnnualRanges);
Assert.True(applicability.Covers(new DateOnly(2027, 1, 1)));
Assert.False(applicability.Covers(new DateOnly(2027, 3, 1)));
}
[Fact]
public async Task Season_DoesNotSpendTheWeeksBudget()
{
// Праздничная неделя идёт вместо обычной, а не вдобавок к ней: её слоты не должны попадать
// в подсчёт закрытого времени, иначе генератор решит, что неделя перекрыта с запасом.
var fixture = new TestDb();
var series = Playable("Сериал", ShowKind.Series, 40, 25);
var films = Films(8, 100);
var (channelId, _) = await SeedChannelAsync(
fixture,
[series.Show, .. films.Shows],
[.. series.Assets, .. films.Assets],
[
GroupOf("Сериалы", series.Show.Id),
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
]
);
var plan = await PlanAsync(
fixture,
channelId,
GridProfileKind.Mixed,
GridGenerationMode.Rebuild
);
Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Season);
Assert.All(
plan.Slots.Where(s => s.Layer == GridPlanLayer.Season),
s => Assert.Null(s.Weekday)
);
Assert.True(plan.CoveredMinutes <= plan.FreeMinutes);
}
[Fact]
public void Sitcoms_AirInPairs()
{
// Ситком — это пара серий и рекламный шов между парами. Три-четыре подряд читаются как
// марафон, а марафон у ситкомового канала бывает по выходным, а не каждый будний день.
var sitcom = GridProfiles.Get(GridProfileKind.Sitcom);
var blocks = sitcom
.Weekdays.Where(b => b.PreferKind == ShowKind.Series && b.UnitsPerBlock > 0)
.ToList();
Assert.NotEmpty(blocks);
Assert.All(blocks, b => Assert.Equal(2, b.UnitsPerBlock));
}
[Fact]
public async Task SlotLength_FollowsContent_RoundedToTheGrid()
{
// Серии по 25 минут: двухчасовая полоса должна разложиться на блоки под серии, а не
// отдать десятки минут фону. Длина при этом округляется вверх до четверти часа —
// программа передач состоит из круглых времён, а разницу занимает рекламный шов.
var fixture = new TestDb(); var fixture = new TestDb();
var series = Playable("Сериал", ShowKind.Series, 60, 25); var series = Playable("Сериал", ShowKind.Series, 60, 25);
var (channelId, _) = await SeedChannelAsync( var (channelId, _) = await SeedChannelAsync(
@@ -342,8 +485,12 @@ public class GenerateGridTests
morning, morning,
slot => slot =>
{ {
// Слот вмещает ровно столько серий, сколько в нём заявлено, и с точностью до пяти минут. var content = slot.BlockValue * 25;
Assert.Equal(slot.DurationMinutes, slot.BlockValue * 25); // Слот не короче своего контента — иначе блок перелезет через объявленное время.
Assert.True(slot.DurationMinutes >= content);
// И не длиннее его больше, чем на шаг решётки: остаток — это шов, а не пустота.
Assert.True(slot.DurationMinutes - content < 15);
Assert.Equal(0, slot.DurationMinutes % 15);
Assert.Equal(SlotBlockMode.Count, slot.BlockMode); Assert.Equal(SlotBlockMode.Count, slot.BlockMode);
} }
); );
@@ -116,7 +116,10 @@ public class GridPlanPreviewTests
await using var db = fixture.New(); await using var db = fixture.New();
var result = await new PreviewGeneratedGridQueryHandler( var result = await new PreviewGeneratedGridQueryHandler(
new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) new GridPlanner(
db,
new GroupCatalog(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
)
).Handle( ).Handle(
new PreviewGeneratedGridQuery( new PreviewGeneratedGridQuery(
channelId, channelId,
@@ -143,7 +146,10 @@ public class GridPlanPreviewTests
await using var db = fixture.New(); await using var db = fixture.New();
var result = await new PreviewGeneratedGridQueryHandler( var result = await new PreviewGeneratedGridQueryHandler(
new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db)) new GridPlanner(
db,
new GroupCatalog(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
)
).Handle( ).Handle(
new PreviewGeneratedGridQuery( new PreviewGeneratedGridQuery(
Guid.NewGuid(), Guid.NewGuid(),
@@ -0,0 +1,378 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.Transfer;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Обмен конфигурацией сетки: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ.
/// Главное свойство — ссылки в файле именные: он должен переноситься между каналами и установками,
/// где тех же идентификаторов нет.
/// </summary>
public class GridTransferTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static (Show Show, MediaAsset Asset) Playable(string name, int minutes)
{
var show = Show.Create(name, ShowKind.Series);
var asset = MediaAsset.Register($"{name}.mkv", ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
TimeSpan.FromMinutes(minutes),
6,
minutes * 10,
1920,
1080,
"h264",
"aac",
$"assets/{name}"
)
);
show.AddEpisode(asset.Id);
return (show, asset);
}
/// <summary>Канал с одной группой, одним стыком и слоем «Основная сетка» из одного слота.</summary>
private static async Task<Guid> SeedAsync(TestDb fixture, bool withSlot = true)
{
var (show, asset) = Playable("Сериал", 25);
var group = Group.Create("Сериалы");
group.AddElement(GroupElementKind.Show, show.Id);
var junction = JunctionTemplate.Create("Рекламный шов");
var channel = Channel.Create("Первый", "first", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
channel.SetTemplate(template.Id);
template.SetFallbackGroup(group.Id);
template.SetDefaultJunction(junction.Id);
var layer = template.AddLayer("Основная сетка", 10);
if (withSlot)
{
var slot = layer.AddSlot("Вечерний блок", new TimeOnly(20, 0), 120, Daypart.Prime);
slot.UpdateTiming(null, new TimeOnly(20, 0), 120, Daypart.Prime, true, 5, 15);
slot.UpdateContent(
new SlotContent(
"Вечерний блок",
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.Count,
4,
OverflowPolicy.ContinueNext,
null,
junction.Id
)
);
}
await using var seed = fixture.New();
seed.MediaAssets.Add(asset);
seed.Shows.Add(show);
seed.Groups.Add(group);
seed.JunctionTemplates.Add(junction);
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return channel.Id;
}
private static async Task<GridConfig> ExportAsync(TestDb fixture, Guid channelId)
{
await using var db = fixture.New();
var result = await new ExportGridQueryHandler(db).Handle(
new ExportGridQuery(channelId),
CancellationToken.None
);
Assert.True(result.IsSuccess);
return result.Value;
}
private static async Task<GridImportResultDto> ImportAsync(
TestDb fixture,
Guid channelId,
GridConfig config,
bool replace = true
)
{
await using var db = fixture.New();
var result = await new ImportGridCommandHandler(db, new SlotWriter(db)).Handle(
new ImportGridCommand(channelId, config, replace),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
return result.Value;
}
[Fact]
public async Task Export_ReferencesGroupsAndJunctionsByName()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
var config = await ExportAsync(fixture, channelId);
Assert.Equal(GridConfig.CurrentFormat, config.Format);
Assert.Equal(GridConfig.CurrentVersion, config.Version);
Assert.Equal("Сериалы", config.FallbackGroup);
Assert.Equal("Рекламный шов", config.DefaultJunction);
Assert.Equal("Первый", config.Channel?.Name);
var layer = Assert.Single(config.Layers, l => !l.IsBackground);
var slot = Assert.Single(layer.Slots);
Assert.Equal("Сериалы", slot.Group);
Assert.Equal("Рекламный шов", slot.JunctionAfter);
Assert.True(slot.IsAnchor);
Assert.Equal(15, slot.SnapToMinutes);
// Файл должен быть читаемым JSON — его правят руками и отдают модели.
Assert.Contains("\"format\": \"telewave.grid\"", config.ToJson(), StringComparison.Ordinal);
}
[Fact]
public async Task Import_RestoresTheSameGrid_AfterExport()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
var config = await ExportAsync(fixture, channelId);
// Через сериализацию — так файл и приходит обратно, а не объектом в памяти.
var roundTripped = JsonSerializer.Deserialize<GridConfig>(
config.ToJson(),
GridConfig.Options
);
Assert.NotNull(roundTripped);
var imported = await ImportAsync(fixture, channelId, roundTripped);
Assert.Equal(1, imported.Slots);
Assert.Equal(0, imported.Skipped);
Assert.Equal(1, imported.Removed);
Assert.Empty(imported.Warnings);
var restored = await ExportAsync(fixture, channelId);
Assert.Equal(config.Layers.Count, restored.Layers.Count);
Assert.Equal(
config.Layers.SelectMany(l => l.Slots),
restored.Layers.SelectMany(l => l.Slots)
);
}
[Fact]
public async Task Import_ReusesLayerByName_AndDoesNotDuplicateIt()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
var config = await ExportAsync(fixture, channelId);
await ImportAsync(fixture, channelId, config);
await ImportAsync(fixture, channelId, config);
await using var db = fixture.New();
var template = await db
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstAsync(CancellationToken.None);
Assert.Single(template.Layers, l => l.Name == "Основная сетка");
Assert.Equal(1, template.Layers.Sum(l => l.Slots.Count));
}
[Fact]
public async Task Import_SkipsUnknownGroup_AndSaysSo()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture, withSlot: false);
var config = new GridConfig([
new GridConfigLayer(
"Основная сетка",
10,
[
new GridConfigSlot(
"Чужой блок",
new TimeOnly(20, 0),
120,
Daypart.Prime,
Group: "Сериалы с другого канала"
),
new GridConfigSlot(
"Свой блок",
new TimeOnly(22, 0),
60,
Daypart.Night,
Group: "Сериалы"
),
]
),
]);
var imported = await ImportAsync(fixture, channelId, config);
// Одна незнакомая ссылка не должна ронять весь файл — иначе админ чинит JSON руками.
Assert.Equal(1, imported.Slots);
Assert.Equal(1, imported.Skipped);
Assert.Contains(
imported.Warnings,
w => w.Contains("Сериалы с другого канала", StringComparison.Ordinal)
);
}
[Fact]
public async Task Import_SkipsOverlappingSlot_AndKeepsTheGridValid()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture, withSlot: false);
var config = new GridConfig([
new GridConfigLayer(
"Основная сетка",
10,
[
new GridConfigSlot(
"Первый",
new TimeOnly(20, 0),
120,
Daypart.Prime,
Group: "Сериалы"
),
new GridConfigSlot(
"Наложился",
new TimeOnly(21, 0),
60,
Daypart.Prime,
Group: "Сериалы"
),
]
),
]);
var imported = await ImportAsync(fixture, channelId, config);
Assert.Equal(1, imported.Slots);
Assert.Equal(1, imported.Skipped);
Assert.NotEmpty(imported.Warnings);
}
[Fact]
public async Task Import_MarksTemplateChanged_ButDoesNotTouchAir()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
var config = await ExportAsync(fixture, channelId);
await using (var applied = fixture.New())
{
var template = await applied.ScheduleTemplates.FirstAsync(CancellationToken.None);
template.MarkApplied();
await applied.SaveChangesAsync(CancellationToken.None);
}
await ImportAsync(fixture, channelId, config);
await using var db = fixture.New();
var stored = await db.ScheduleTemplates.FirstAsync(CancellationToken.None);
Assert.True(stored.HasPendingChanges);
}
[Fact]
public async Task Prompt_ListsGroupsAndSchema()
{
var fixture = new TestDb();
var channelId = await SeedAsync(fixture);
await using var db = fixture.New();
var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle(
new BuildGridPromptQuery(
channelId,
["2×2", "Paramount Comedy"],
"Хочу ситкомы вечером"
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var prompt = result.Value.Prompt;
// Модель обязана видеть, чем канал располагает и в каком виде ждут ответ.
Assert.Contains("Сериалы", prompt, StringComparison.Ordinal);
Assert.Contains("2×2", prompt, StringComparison.Ordinal);
Assert.Contains("Хочу ситкомы вечером", prompt, StringComparison.Ordinal);
Assert.Contains("Рекламный шов", prompt, StringComparison.Ordinal);
Assert.Contains(GridConfig.CurrentFormat, prompt, StringComparison.Ordinal);
Assert.Equal(1, result.Value.Groups);
Assert.Equal(1, result.Value.Shows);
Assert.Equal(prompt.Length, result.Value.Characters);
}
[Fact]
public async Task Prompt_SchemaSurvivesImport()
{
// Схема в запросе и импорт обязаны быть одним форматом: пример из запроса должен читаться
// импортом без правок, иначе ответ модели придётся чинить руками.
var fixture = new TestDb();
var channelId = await SeedAsync(fixture, withSlot: false);
var answer = """
{
"format": "telewave.grid",
"version": 1,
"fallbackGroup": "Сериалы",
"layers": [
{
"name": "Основная сетка",
"priority": 10,
"slots": [
{
"title": "Вечерний блок",
"start": "20:00",
"durationMinutes": 120,
"daypart": "Prime",
"kind": "Content",
"group": "Сериалы",
"blockMode": "Count",
"blockValue": 4,
"strategy": { "type": "Sequential", "restartOnEnd": true },
"isAnchor": true,
"snapToMinutes": 15
}
]
}
]
}
""";
var config = JsonSerializer.Deserialize<GridConfig>(answer, GridConfig.Options);
Assert.NotNull(config);
var imported = await ImportAsync(fixture, channelId, config);
Assert.Equal(1, imported.Slots);
Assert.Empty(imported.Warnings);
var stored = await ExportAsync(fixture, channelId);
var slot = Assert.Single(stored.Layers.SelectMany(l => l.Slots));
Assert.Equal("Вечерний блок", slot.Title);
Assert.Equal(SlotBlockMode.Count, slot.BlockMode);
Assert.True(slot.IsAnchor);
}
private static GroupCatalog Catalog(IAppDbContext db) =>
new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));
}
@@ -42,7 +42,8 @@ public class JunctionFillerTests
PlanningTimeWindow? window = null, PlanningTimeWindow? window = null,
string? choiceKey = null, string? choiceKey = null,
int choiceWeight = 1, int choiceWeight = 1,
int poolSize = 8 int poolSize = 8,
int nearHourMinutes = 0
) => ) =>
new( new(
Guid.NewGuid(), Guid.NewGuid(),
@@ -55,6 +56,7 @@ public class JunctionFillerTests
minMinutesBetween, minMinutesBetween,
chance, chance,
window, window,
nearHourMinutes,
choiceKey, choiceKey,
choiceWeight choiceWeight
); );
@@ -234,6 +236,58 @@ public class JunctionFillerTests
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
} }
[Fact]
public void NearHour_SkipsAwayFromRoundHour()
{
// Серии по 20 минут: стыки приходятся на 18:20 и 18:40 — сигналу точного времени там не место.
var element = Series(3, 20, out _);
var result = Run(
Slot(
element,
3,
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
)
);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
}
[Fact]
public void NearHour_PlacesOnTheHour()
{
// Часовые серии: стыки ровно в 19:00 и 20:00 — врезка с допуском срабатывает на каждом.
var element = Series(3, 60, out _);
var result = Run(
Slot(
element,
3,
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
)
);
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
Assert.Equal(2, ads.Count);
Assert.All(ads, ad => Assert.Equal(0, ad.StartsAtUtc.Minute));
}
[Fact]
public void NearHour_CountsChannelTime_NotUtc()
{
// Канал в UTC+3:30: круглый час канала приходится на :30 по UTC, и считать надо по каналу —
// «московское время» объявляют в эфире, а не в UTC.
var element = Series(3, 60, out _);
var result = Run(
Slot(
element,
3,
between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
),
utcOffsetMinutes: 210
);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
}
[Fact] [Fact]
public void Choice_PlacesExactlyOneOfTheFork() public void Choice_PlacesExactlyOneOfTheFork()
{ {
@@ -75,7 +75,9 @@ public class SchedulePlannerTests
DateTimeOffset? start = null, DateTimeOffset? start = null,
int horizonHours = 6, int horizonHours = 6,
IReadOnlyList<PlanningUnit>? fallback = null, IReadOnlyList<PlanningUnit>? fallback = null,
IReadOnlyList<PlanningSlot>? background = null IReadOnlyList<PlanningSlot>? background = null,
IReadOnlyList<PadUnit>? pad = null,
TimeSpan maxPad = default
) => ) =>
new( new(
Guid.NewGuid(), Guid.NewGuid(),
@@ -85,9 +87,13 @@ public class SchedulePlannerTests
fallback ?? [Unit(1, Guid.NewGuid(), 0)], fallback ?? [Unit(1, Guid.NewGuid(), 0)],
SegmentSeconds: 2, SegmentSeconds: 2,
UtcOffsetMinutes: 0, UtcOffsetMinutes: 0,
background ?? [] background ?? [],
pad ?? [],
maxPad
); );
private static PadUnit Ad(int minutes) => new(Unit(minutes, Guid.Empty, 0), PlannedItemKind.Ad);
private static PlanningResult Run(PlanningInput input) => private static PlanningResult Run(PlanningInput input) =>
TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways()); TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways());
@@ -350,6 +356,72 @@ public class SchedulePlannerTests
Assert.Equal(element.ElementId, program.Trace.ElementId); Assert.Equal(element.ElementId, program.Trace.ElementId);
} }
[Fact]
public void Pad_FillsSeamBeforeSlotWithAds()
{
// Слот-якорь начинается через полчаса после конца предыдущего: шов до объявленного времени
// на настоящем ТВ занимает реклама, а не ещё одна серия.
var anchor = Slot(T0.AddMinutes(30), 30, [Element(1, 30)], isAnchor: true);
var result = Run(
Input(
[Slot(T0, 15, [Element(1, 15)]), anchor],
horizonHours: 1,
pad: [Ad(5)],
maxPad: TimeSpan.FromMinutes(30)
)
);
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
Assert.Equal(3, ads.Count);
Assert.Equal(T0.AddMinutes(15), ads[0].StartsAtUtc);
// К объявленному времени шов заканчивается: якорь начинается ровно в свои полчаса.
Assert.Equal(anchor.TargetStartUtc, ads[^1].EndsAtUtc);
}
[Fact]
public void Pad_IsCappedSoLongHolesStayProgramming()
{
// Дыра в час: рекламой закрывается только потолок одного добора, остальное — программы.
var background = Slot(T0, 24 * 60, [Element(6, 10)]);
var result = Run(
Input(
[Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)],
horizonHours: 2,
pad: [Ad(5)],
maxPad: TimeSpan.FromMinutes(10),
background: [background]
)
);
var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
Assert.Equal(
TimeSpan.FromMinutes(10),
ads.Aggregate(TimeSpan.Zero, (sum, a) => sum + (a.EndsAtUtc - a.StartsAtUtc))
);
Assert.Contains(result.Items, i => i.SlotId == background.SlotId);
}
[Fact]
public void Pad_ClosesTailWhenProgrammeDoesNotFit()
{
// Остаток в пять минут: серия фона туда не влезает, и хвост занимает реклама, а не филлер.
var background = Slot(T0, 24 * 60, [Element(4, 30)]);
var result = Run(
Input(
[Slot(T0, 55, [Element(1, 55)])],
horizonHours: 1,
pad: [Ad(5)],
background: [background]
)
);
Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad);
Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Fallback);
}
[Fact] [Fact]
public void Background_PlaysInLeftoverInsteadOfEmergencyFiller() public void Background_PlaysInLeftoverInsteadOfEmergencyFiller()
{ {
+52 -3
View File
@@ -454,7 +454,8 @@ JunctionElement
"minMinutesBetween": 30, "minMinutesBetween": 30,
"dayparts": ["prime", "day"], "dayparts": ["prime", "day"],
"timeWindow": { "from": "20:00", "to": "23:00" }, "timeWindow": { "from": "20:00", "to": "23:00" },
"chance": 40 "chance": 40,
"nearHourMinutes": 2
} }
``` ```
@@ -465,6 +466,10 @@ JunctionElement
стыков ставим анонс». `minMinutesBetween` считается **по конкретной врезке**, а не по её виду: стыков ставим анонс». `minMinutesBetween` считается **по конкретной врезке**, а не по её виду:
две рекламные врезки в разных стыках — это разные ограничения, общий счётчик на вид склеил бы их. две рекламные врезки в разных стыках — это разные ограничения, общий счётчик на вид склеил бы их.
`nearHourMinutes` — допуск от круглого часа по времени канала. Сигнал точного времени и джингл тем
и узнаются, что звучат в :00; врезка с допуском ставится только в его окрестности, а не «когда-нибудь
в этом часе». Ноль — привязки нет.
**Развилка** (`choiceKey`) — несколько врезок с одной меткой, из которых играет одна, выбранная по **Развилка** (`choiceKey`) — несколько врезок с одной меткой, из которых играет одна, выбранная по
весам: «иногда заставка, иногда короткий рекламный блок». Врезки одной развилки обязаны занимать весам: «иногда заставка, иногда короткий рекламный блок». Врезки одной развилки обязаны занимать
непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия
@@ -565,11 +570,20 @@ BumperLine
| `{now.episode}` `{next.episode}` | «с5э12» либо название серии | | `{now.episode}` `{next.episode}` | «с5э12» либо название серии |
| `{next.year}` `{next.genre}` | из метаданных | | `{next.year}` `{next.genre}` | из метаданных |
| `{next.time}` | во сколько начнётся следующая программа | | `{next.time}` | во сколько начнётся следующая программа |
| `{tonight.title}` `{tonight.time}` | первая программа прайма этих вещательных суток |
| `{tomorrow.title}` `{tomorrow.time}` | то же для следующих суток |
| `{time}` `{date}` `{weekday}` | момент показа заставки во времени канала | | `{time}` `{date}` `{weekday}` | момент показа заставки во времени канала |
| `{slot}` | название слота — «Вечернее кино» | | `{slot}` | название слота — «Вечернее кино» |
Неизвестное значение подставляется пустым, строка со схлопнувшимися пробелами не рисуется. Анонсы смотрят **вперёд по собранной ленте** и только на программы, которые ещё не начались:
Неизвестный плейсхолдер — **ошибка валидации при сохранении**, а не сюрприз в эфире. «сегодня в 20:00 — Терминатор», сказанное в девять вечера, хуже, чем молчание. Ночь до
`dayStartTime` относится к предыдущим вещательным суткам, иначе «сегодня вечером» в час ночи
означало бы уже следующий вечер.
Неизвестное значение подставляется пустым. Строка, в которой **не подставился ни один**
плейсхолдер, не рисуется целиком: она писалась ради данных, и «ДАЛЕЕ В» без времени — это не
подпись, а мусор в кадре. Постоянного текста без плейсхолдеров это не касается. Неизвестный
плейсхолдер — **ошибка валидации при сохранении**, а не сюрприз в эфире.
Цена гибкости — кэш: `{time}` и `{date}` делают каждый показ уникальным, а рендер это ffmpeg Цена гибкости — кэш: `{time}` и `{date}` делают каждый показ уникальным, а рендер это ffmpeg
на несколько секунд, помноженный на недельный горизонт. Запрещать нечего, но редактор обязан на несколько секунд, помноженный на недельный горизонт. Запрещать нечего, но редактор обязан
@@ -1034,6 +1048,15 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
не лучшего кандидата, а следующего по кругу: иначе неделя выглядит одним повторяющимся днём. не лучшего кандидата, а следующего по кругу: иначе неделя выглядит одним повторяющимся днём.
Остальные полосы остаются слотами «каждый день» — сетка должна читаться. Остальные полосы остаются слотами «каждый день» — сетка должна читаться.
**Праздничная сетка — сезонный слой.** В режиме «с нуля» генератор строит ещё один слой поверх
основного и выходных, с ежегодной применимостью (25 декабря — 8 января): днём марафон, вечером кино.
Он один и тот же у всех профилей намеренно — в праздники каналы сходятся к одному ритму, и различает
их библиотека, а не сетка. Слоты слоя идут «каждый день»: период занимает несколько дат подряд, и
расписывать их по дням недели значило бы получить сетку, зависящую от того, на какой день выпало
31 декабря. Свой разбор и свой бюджет: праздничная неделя идёт **вместо** обычной, а не вдобавок,
и обеднять ради неё обычную нельзя. Даты и содержимое слоя дальше правятся как у любого другого —
повторная генерация применимость уже созданного слоя не переписывает.
**Жанр не занимает две полосы подряд**, а при заданном `MaxGenreSharePercent` — и больше своей **Жанр не занимает две полосы подряд**, а при заданном `MaxGenreSharePercent` — и больше своей
доли суток. Это тот же потолок, который потом проверяет пост-проверка из 3.8, но соблюдаемый доли суток. Это тот же потолок, который потом проверяет пост-проверка из 3.8, но соблюдаемый
на раскладке, а не констатируемый после. на раскладке, а не констатируемый после.
@@ -1045,6 +1068,32 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
с тем, что потом скажут проверки сетки. Эфир генерация не двигает — как любая правка сетки, она с тем, что потом скажут проверки сетки. Эфир генерация не двигает — как любая правка сетки, она
только поднимает ревизию шаблона, а хвост пересобирает кнопка применения. только поднимает ревизию шаблона, а хвост пересобирает кнопка применения.
### 6.10. Обмен конфигурацией сетки
Сетка переносится файлом: выгрузка, загрузка и запрос к ИИ — три вкладки одного диалога.
**Ссылки в файле именные.** Группа, стык и аварийная группа записываются названиями, а не
идентификаторами: файл едет на другой канал и на другую установку, где тех же GUID нет, а сетку для
импорта пишет в том числе языковая модель — идентификаторы она может только выдумать. Импорт
разрешает имена сам; незнакомое имя не валит загрузку, а становится замечанием, и админ видит
списком, чего в библиотеке не нашлось. Тем же способом пропускаются слоты, которые не проходят
проверки (перекрытие, нет группы у содержательного слота).
Слой берётся по имени, поэтому повторная загрузка того же файла даёт ту же сетку, а не
«Основная сетка (2)». Режим «снести существующие слоты» работает как пересборка из 6.9. Эфир
импорт не двигает: он помечает шаблон изменённым, а хвост пересобирает кнопка применения.
**Запрос к ИИ собирается на сервере и никуда не отправляется.** Ключей внешних сервисов проект
не хранит, и заводить их ради одной кнопки не нужно: админ вводит референс-каналы («как у 2×2»)
и пожелания своими словами, получает готовый текст, копирует его в ту модель, которой пользуется,
и приносит ответ назад вкладкой импорта.
В запрос входят параметры канала, доступные группы с их ёмкостью и средней длиной единицы (те же
числа, по которым раскладывает автосборка, — считает их общий `GroupCatalog`), библиотека справкой,
имена стыков и заставок, правила раскладки и схема ответа. Схема лежит рядом с форматом
(`GridPromptText` в одной папке с `GridConfig`), а не в шаблоне на фронте: разойдись она с импортом
— ответ модели пришлось бы чинить руками.
--- ---
## 7. Порядок реализации ## 7. Порядок реализации
@@ -20,6 +20,10 @@ export const PLACEHOLDERS: PlaceholderSample[] = [
{ token: 'next.year', sample: '1991' }, { token: 'next.year', sample: '1991' },
{ token: 'next.genre', sample: 'Боевик' }, { token: 'next.genre', sample: 'Боевик' },
{ token: 'next.time', sample: '21:30' }, { token: 'next.time', sample: '21:30' },
{ token: 'tonight.title', sample: 'Терминатор 2' },
{ token: 'tonight.time', sample: '20:00' },
{ token: 'tomorrow.title', sample: 'Чужие' },
{ token: 'tomorrow.time', sample: '20:00' },
{ token: 'time', sample: '21:24' }, { token: 'time', sample: '21:24' },
{ token: 'date', sample: '6 апреля' }, { token: 'date', sample: '6 апреля' },
{ token: 'weekday', sample: 'понедельник' }, { token: 'weekday', sample: 'понедельник' },
@@ -33,12 +37,29 @@ const TOKEN_PATTERN = /\{([a-zA-Z][a-zA-Z.]*)\}/g
const SAMPLES = new Map(PLACEHOLDERS.map((p) => [p.token, p.sample])) const SAMPLES = new Map(PLACEHOLDERS.map((p) => [p.token, p.sample]))
/** Как строка будет выглядеть в кадре: подстановка образцами + схлопывание лишних пробелов. */ /**
* Как строка будет выглядеть в кадре. Зеркало серверного `BumperPlaceholders.Resolve`: пустые
* значения схлопываются вместе с осиротевшими разделителями, а строка, где не подставился ни один
* плейсхолдер, исчезает целиком «ДАЛЕЕ В» без времени это не подпись, а мусор в кадре.
*/
export function resolveSample(text: string) { export function resolveSample(text: string) {
return text let tokens = 0
.replace(TOKEN_PATTERN, (_, token: string) => SAMPLES.get(token) ?? '') let filled = 0
const resolved = text.replace(TOKEN_PATTERN, (_, token: string) => {
tokens += 1
const value = SAMPLES.get(token) ?? ''
if (value) filled += 1
return value
})
if (tokens > 0 && filled === 0) return ''
return resolved
.replace(/[ \t]{2,}/g, ' ') .replace(/[ \t]{2,}/g, ' ')
.trim() .trim()
.replace(/^[—–\-:·,;/]+|[—–\-:·,;/]+$/g, '')
.trim()
} }
/** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */ /** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */
@@ -164,6 +164,7 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
{tab === 'grid' && ( {tab === 'grid' && (
<GridTab <GridTab
channelId={channelId} channelId={channelId}
channelName={channel.name}
template={template} template={template}
templateError={templateError} templateError={templateError}
onChanged={invalidate} onChanged={invalidate}
@@ -7,7 +7,10 @@ import type {
CreatedIdResponse, CreatedIdResponse,
EntryTraceDto, EntryTraceDto,
GenerateGridResultDto, GenerateGridResultDto,
GridConfig,
GridGenerationMode, GridGenerationMode,
GridImportResultDto,
GridPromptDto,
GridPlanDto, GridPlanDto,
GridProfileDto, GridProfileDto,
GridProfileKind, GridProfileKind,
@@ -121,6 +124,33 @@ export function generateGrid(
}) })
} }
/** Выгрузка сетки одним файлом: перенос, бэкап перед экспериментом и образец для ИИ. */
export function exportGrid(channelId: string) {
return apiRequest<GridConfig>(`/admin/channels/${channelId}/template/export`)
}
/**
* Загрузка сетки из файла. `replace` снести существующие слоты и построить заново; иначе слои
* и слоты добавляются к тому, что уже есть.
*/
export function importGrid(channelId: string, config: GridConfig, replace: boolean) {
return apiRequest<GridImportResultDto>(`/admin/channels/${channelId}/template/import`, {
method: 'POST',
body: { config, replace },
})
}
/**
* Собирает текст запроса к ИИ по референс-каналам и пожеланиям. Никуда не отправляется админ
* копирует его в свою модель и приносит ответ назад кнопкой импорта.
*/
export function buildGridPrompt(channelId: string, references: string[], notes: string) {
return apiRequest<GridPromptDto>(`/admin/channels/${channelId}/template/ai-prompt`, {
method: 'POST',
body: { references, notes: notes || null },
})
}
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */ /** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
export function copyTemplateTo(channelId: string, targetChannelId: string) { export function copyTemplateTo(channelId: string, targetChannelId: string) {
return apiRequest<CopyTemplateResultDto>( return apiRequest<CopyTemplateResultDto>(
@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { Plus, Wand2 } from 'lucide-react' import { FileJson, Plus, Wand2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { HttpError } from '@/shared/api/client'
@@ -22,6 +22,7 @@ import {
} from '../api' } from '../api'
import { toTime } from '../lib/format' import { toTime } from '../lib/format'
import { GenerateGridDialog } from './GenerateGridDialog' import { GenerateGridDialog } from './GenerateGridDialog'
import { GridTransferDialog } from './GridTransferDialog'
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog' import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './ScheduleGrid' import { LayerList, ScheduleGrid } from './ScheduleGrid'
import { SlotInspector, type SlotDraft } from './SlotInspector' import { SlotInspector, type SlotDraft } from './SlotInspector'
@@ -35,12 +36,14 @@ import { TemplatePreview } from './TemplatePreview'
*/ */
export function GridTab({ export function GridTab({
channelId, channelId,
channelName,
template, template,
templateError, templateError,
onChanged, onChanged,
onError, onError,
}: Readonly<{ }: Readonly<{
channelId: string channelId: string
channelName: string
template: ScheduleTemplateDto | undefined template: ScheduleTemplateDto | undefined
templateError: unknown templateError: unknown
onChanged: () => void onChanged: () => void
@@ -52,6 +55,7 @@ export function GridTab({
const [viewDate, setViewDate] = useState<string>('') const [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null) const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [transferring, setTransferring] = useState(false)
// День, который копируем, и отмеченные дни-приёмники. // День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null) const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([]) const [copyTargets, setCopyTargets] = useState<number[]>([])
@@ -308,6 +312,10 @@ export function GridTab({
<Wand2 className="h-4 w-4" /> <Wand2 className="h-4 w-4" />
{t('admin.channels.generate.action')} {t('admin.channels.generate.action')}
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => setTransferring(true)}>
<FileJson className="h-4 w-4" />
{t('admin.channels.transfer.action')}
</Button>
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span> <span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input <Input
type="date" type="date"
@@ -378,6 +386,16 @@ export function GridTab({
</CardContent> </CardContent>
</Card> </Card>
{transferring && (
<GridTransferDialog
channelId={channelId}
channelName={channelName}
onClose={() => setTransferring(false)}
onImported={onChanged}
onError={onError}
/>
)}
{generating && ( {generating && (
<GenerateGridDialog <GenerateGridDialog
channelId={channelId} channelId={channelId}
@@ -0,0 +1,305 @@
import { useMutation } from '@tanstack/react-query'
import { AlertTriangle, Copy, Download, Sparkles, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { GridConfig } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { buildGridPrompt, exportGrid, importGrid } from '../api'
type Tab = 'export' | 'import' | 'ai'
const FIELD = 'w-full rounded-sm border border-border bg-transparent px-3 py-2 text-sm'
const TABS: Tab[] = ['export', 'import', 'ai']
/** Файл конфигурации в человекочитаемом виде — его правят руками и отдают модели. */
function download(channelName: string, config: GridConfig) {
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${channelName || 'grid'}.telewave.json`
link.click()
URL.revokeObjectURL(url)
}
/**
* Обмен конфигурацией сетки: выгрузить файлом, загрузить чужой файл, собрать запрос к ИИ.
*
* Запрос к модели никуда не отправляется: ключей внешних сервисов проект не хранит, и заводить их
* ради одной кнопки не нужно. Админ копирует текст в ту модель, которой пользуется, и приносит
* ответ назад той же вкладкой импорта формат у запроса и у импорта один.
*/
export function GridTransferDialog({
channelId,
channelName,
onClose,
onImported,
onError,
}: Readonly<{
channelId: string
channelName: string
onClose: () => void
onImported: () => void
onError: (error: unknown) => void
}>) {
const { t } = useTranslation()
const [tab, setTab] = useState<Tab>('export')
const [text, setText] = useState('')
const [replace, setReplace] = useState(true)
const [warnings, setWarnings] = useState<string[]>([])
const fileInput = useRef<HTMLInputElement>(null)
const [references, setReferences] = useState('')
const [notes, setNotes] = useState('')
const [prompt, setPrompt] = useState('')
const exportMutation = useMutation({
mutationFn: () => exportGrid(channelId),
onSuccess: (config) => {
download(channelName, config)
toast.success(t('admin.channels.transfer.exported'))
},
onError,
})
const importMutation = useMutation({
mutationFn: () => {
// Разбор здесь, а не в мутации-обёртке: битый JSON — самая частая ошибка, и сказать о ней
// надо строкой в диалоге, а не общим «не удалось сохранить».
const config = JSON.parse(text) as GridConfig
return importGrid(channelId, config, replace)
},
onSuccess: (result) => {
setWarnings(result.warnings)
toast.success(
t('admin.channels.transfer.imported', {
slots: result.slots,
skipped: result.skipped,
}),
)
onImported()
},
onError: (error: unknown) => {
if (error instanceof SyntaxError) {
toast.error(t('admin.channels.transfer.badJson'))
return
}
onError(error)
},
})
const promptMutation = useMutation({
mutationFn: () =>
buildGridPrompt(
channelId,
references
.split(/[\n,]/)
.map((r) => r.trim())
.filter(Boolean),
notes,
),
onSuccess: (result) => setPrompt(result.prompt),
onError,
})
const copyPrompt = async () => {
await navigator.clipboard.writeText(prompt)
toast.success(t('admin.channels.transfer.copied'))
}
const pickFile = (file: File | undefined) => {
if (!file) return
file
.text()
.then((content) => {
setText(content)
setWarnings([])
})
.catch(onError)
}
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.transfer.title')}</DialogTitle>
<DialogDescription>{t('admin.channels.transfer.hint')}</DialogDescription>
</DialogHeader>
<div className="flex gap-1 border-b border-border">
{TABS.map((item) => (
<button
key={item}
type="button"
onClick={() => setTab(item)}
className={`-mb-px border-b-2 px-3 py-1.5 text-sm ${
tab === item
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t(`admin.channels.transfer.tabs.${item}`)}
</button>
))}
</div>
{tab === 'export' && (
<div className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">{t('admin.channels.transfer.exportHint')}</p>
<div>
<Button
size="sm"
onClick={() => exportMutation.mutate()}
disabled={exportMutation.isPending}
>
<Download className="h-4 w-4" />
{t('admin.channels.transfer.exportAction')}
</Button>
</div>
</div>
)}
{tab === 'import' && (
<div className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">{t('admin.channels.transfer.importHint')}</p>
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInput}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => pickFile(e.target.files?.[0])}
/>
<Button size="sm" variant="outline" onClick={() => fileInput.current?.click()}>
<Upload className="h-4 w-4" />
{t('admin.channels.transfer.pickFile')}
</Button>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={replace}
onChange={(e) => setReplace(e.target.checked)}
/>
{t('admin.channels.transfer.replace')}
</label>
</div>
<textarea
rows={12}
className={FIELD + ' font-mono text-xs'}
placeholder={t('admin.channels.transfer.pastePlaceholder')}
value={text}
onChange={(e) => setText(e.target.value)}
/>
{warnings.length > 0 && (
<div className="flex flex-col gap-1 rounded border border-amber-500/40 bg-amber-500/10 p-2 text-xs">
<span className="flex items-center gap-1.5 font-medium text-amber-500">
<AlertTriangle className="h-3.5 w-3.5" />
{t('admin.channels.transfer.warnings')}
</span>
{warnings.map((warning) => (
<span key={warning} className="text-muted-foreground">
{warning}
</span>
))}
</div>
)}
<div>
<Button
size="sm"
disabled={!text.trim() || importMutation.isPending}
onClick={() => importMutation.mutate()}
>
<Upload className="h-4 w-4" />
{t('admin.channels.transfer.importAction')}
</Button>
</div>
</div>
)}
{tab === 'ai' && (
<div className="flex flex-col gap-3 text-sm">
<p className="text-muted-foreground">{t('admin.channels.transfer.aiHint')}</p>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.transfer.references')}</Label>
<Input
value={references}
placeholder={t('admin.channels.transfer.referencesPlaceholder')}
onChange={(e) => setReferences(e.target.value)}
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.transfer.referencesHint')}
</span>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.transfer.notes')}</Label>
<textarea
rows={3}
className={FIELD}
value={notes}
placeholder={t('admin.channels.transfer.notesPlaceholder')}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => promptMutation.mutate()}
disabled={promptMutation.isPending}
>
<Sparkles className="h-4 w-4" />
{t('admin.channels.transfer.buildPrompt')}
</Button>
{prompt && (
<Button size="sm" variant="outline" onClick={copyPrompt}>
<Copy className="h-4 w-4" />
{t('admin.channels.transfer.copyPrompt')}
</Button>
)}
</div>
{prompt && (
<>
<textarea
rows={14}
readOnly
className={FIELD + ' font-mono text-xs'}
value={prompt}
onFocus={(e) => e.currentTarget.select()}
/>
<p className="text-xs text-muted-foreground">
{t('admin.channels.transfer.aiNext')}
</p>
</>
)}
</div>
)}
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -46,6 +46,8 @@ function conditionsHint(element: JunctionElementDto, t: Translate) {
if (c && c.minMinutesBetween > 0) if (c && c.minMinutesBetween > 0)
parts.push(t('admin.junctions.badgeInterval', { minutes: c.minMinutesBetween })) parts.push(t('admin.junctions.badgeInterval', { minutes: c.minMinutesBetween }))
if (c?.timeWindow) parts.push(`${c.timeWindow.from.slice(0, 5)}${c.timeWindow.to.slice(0, 5)}`) if (c?.timeWindow) parts.push(`${c.timeWindow.from.slice(0, 5)}${c.timeWindow.to.slice(0, 5)}`)
if (c && c.nearHourMinutes > 0)
parts.push(t('admin.junctions.badgeNearHour', { minutes: c.nearHourMinutes }))
if (c?.dayparts?.length) if (c?.dayparts?.length)
parts.push(c.dayparts.map((d) => t(`admin.channels.dayparts.${d}`)).join('/')) parts.push(c.dayparts.map((d) => t(`admin.channels.dayparts.${d}`)).join('/'))
return parts.join(' · ') return parts.join(' · ')
@@ -26,6 +26,7 @@ const DEFAULT_CONDITIONS: JunctionConditions = {
dayparts: null, dayparts: null,
timeWindow: null, timeWindow: null,
chance: 100, chance: 100,
nearHourMinutes: 0,
} }
function toBody(element: JunctionElementDto): JunctionElementBody { function toBody(element: JunctionElementDto): JunctionElementBody {
@@ -274,6 +275,31 @@ export function JunctionElementDialog({
</div> </div>
<p className="-mt-1 text-xs text-muted-foreground">{t('admin.junctions.chanceHint')}</p> <p className="-mt-1 text-xs text-muted-foreground">{t('admin.junctions.chanceHint')}</p>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.nearHour')}</Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-32"
min={0}
max={30}
value={conditions.nearHourMinutes}
onChange={(e) =>
setConditions({
nearHourMinutes: Math.min(
30,
Math.max(0, Math.round(Number(e.target.value)) || 0),
),
})
}
/>
<span className="text-xs text-muted-foreground">
{t('admin.junctions.nearHourUnit')}
</span>
</div>
<p className="text-xs text-muted-foreground">{t('admin.junctions.nearHourHint')}</p>
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>{t('admin.junctions.dayparts')}</Label> <Label>{t('admin.junctions.dayparts')}</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
+64 -1
View File
@@ -589,6 +589,8 @@ export type JunctionConditions = {
dayparts?: Daypart[] | null dayparts?: Daypart[] | null
timeWindow?: JunctionTimeWindow | null timeWindow?: JunctionTimeWindow | null
chance: number chance: number
/** Ставить только в пределах ±N минут от круглого часа; 0 — без привязки. */
nearHourMinutes: number
} }
export type JunctionElementDto = { export type JunctionElementDto = {
@@ -762,7 +764,7 @@ export type GridProfileDto = {
/** Строка предпросмотра: будущий слот до того, как он создан. */ /** Строка предпросмотра: будущий слот до того, как он создан. */
export type GridPlanSlotDto = { export type GridPlanSlotDto = {
layer: 'Main' | 'Weekend' layer: 'Main' | 'Weekend' | 'Season'
weekday: number | null weekday: number | null
start: string start: string
durationMinutes: number durationMinutes: number
@@ -791,6 +793,67 @@ export type GridPlanDto = {
export type GenerateGridResultDto = { created: number; removed: number } export type GenerateGridResultDto = { created: number; removed: number }
/**
* Файл обмена конфигурацией сетки. Ссылки на группы и стыки по именам: файл переносится между
* каналами и установками, а сетку для импорта пишет в том числе ИИ, который GUID может только
* выдумать. Схема повторяет серверную GridConfig.
*/
export type GridConfigSlot = {
title: string
start: string
durationMinutes: number
daypart?: Daypart
kind?: SlotKind
weekday?: number | null
group?: string | null
strategy?: SlotStrategy | null
repeat?: RepeatSource | null
blockMode?: SlotBlockMode
blockValue?: number
overflow?: OverflowPolicy
isAnchor?: boolean
maxDriftMinutes?: number
snapToMinutes?: number | null
junctionBetween?: string | null
junctionAfter?: string | null
}
export type GridConfigLayer = {
name: string
priority: number
slots: GridConfigSlot[]
isEnabled?: boolean
isBackground?: boolean
applicability?: LayerApplicability | null
}
export type GridConfig = {
format: string
version: number
channel?: { name: string; dayStart: string; utcOffsetMinutes: number } | null
rules?: PlanningRules | null
fallbackGroup?: string | null
defaultJunction?: string | null
layers: GridConfigLayer[]
}
/** Итог импорта: пропущенные слоты — не ошибка, а список того, чего не нашлось в библиотеке. */
export type GridImportResultDto = {
layers: number
slots: number
skipped: number
removed: number
warnings: string[]
}
/** Готовый запрос к ИИ и то, из чего он собран. */
export type GridPromptDto = {
prompt: string
groups: number
shows: number
characters: number
}
/** Итог отката сетки: сколько слотов осталось и сколько ссылок восстановить не удалось. */ /** Итог отката сетки: сколько слотов осталось и сколько ссылок восстановить не удалось. */
export type RestoreTemplateResultDto = { slots: number; droppedRefs: number } export type RestoreTemplateResultDto = { slots: number; droppedRefs: number }
+41
View File
@@ -429,6 +429,38 @@ export const en = {
addSlotHere: 'Add slot', addSlotHere: 'Add slot',
newSlot: 'New slot', newSlot: 'New slot',
/** Grid auto-build by profile: plan preview and creation. */ /** Grid auto-build by profile: plan preview and creation. */
transfer: {
action: 'Import / export',
title: 'Grid configuration exchange',
hint: 'The file references groups and junctions by name, so it moves between channels and installations. The AI request is not sent anywhere — you copy it into your own model and bring the answer back to the import tab.',
tabs: { export: 'Export', import: 'Import', ai: 'AI request' },
exportHint:
'Exports layers, slots, rules and references to groups and junctions. Works both as a backup before an experiment and as a sample for a model.',
exportAction: 'Download file',
exported: 'File exported',
importHint:
'Load a file or paste JSON — a model answer, for example. Unknown references do not break the import: such slots are skipped and listed below.',
pickFile: 'Pick a file',
replace: 'Remove existing slots',
pastePlaceholder: 'Paste the grid configuration JSON',
importAction: 'Import',
imported: 'Slots created: {{slots}}, skipped: {{skipped}}',
badJson: 'That does not look like JSON — check that the whole answer was copied.',
warnings: 'What could not be resolved',
aiHint:
'We build the request: channel settings, your groups and library, your references and notes, plus the answer schema. The model returns JSON that loads on the import tab.',
references: 'Reference channels',
referencesPlaceholder: 'Cartoon Network, Paramount Comedy',
referencesHint:
'Comma-separated. The model matches their rhythm — block length, prime, night.',
notes: 'Notes',
notesPlaceholder:
'For example: kids in the morning, films in the evening, adult block at night',
buildPrompt: 'Build the request',
copyPrompt: 'Copy',
copied: 'Request copied',
aiNext: 'Send this to your model and paste the JSON you get back on the "Import" tab.',
},
generate: { generate: {
action: 'Build grid', action: 'Build grid',
title: 'Grid auto-build', title: 'Grid auto-build',
@@ -712,6 +744,11 @@ export const en = {
timeWindow: 'Channel time window', timeWindow: 'Channel time window',
clearWindow: 'Clear', clearWindow: 'Clear',
timeWindowHint: 'Empty — any time. The window may cross midnight.', timeWindowHint: 'Empty — any time. The window may cross midnight.',
nearHour: 'Near the round hour',
nearHourUnit: 'min before and after :00',
nearHourHint:
'0 — no anchoring. Time signals and jingles work this way: the break only plays around :00, not somewhere within the hour.',
badgeNearHour: '±{{minutes}} min off the hour',
badgeOnChange: 'on change', badgeOnChange: 'on change',
badgeInterval: 'once per {{minutes}} min', badgeInterval: 'once per {{minutes}} min',
}, },
@@ -792,6 +829,10 @@ export const en = {
'next.year': 'Next show year', 'next.year': 'Next show year',
'next.genre': 'Next show genre', 'next.genre': 'Next show genre',
'next.time': 'Next start time', 'next.time': 'Next start time',
'tonight.title': 'Tonights programme',
'tonight.time': 'Tonights start time',
'tomorrow.title': 'Tomorrows programme',
'tomorrow.time': 'Tomorrows start time',
time: 'Bumper airing time', time: 'Bumper airing time',
date: 'Date', date: 'Date',
weekday: 'Weekday', weekday: 'Weekday',
+40
View File
@@ -430,6 +430,37 @@ export const ru = {
addSlotHere: 'Добавить слот', addSlotHere: 'Добавить слот',
newSlot: 'Новый слот', newSlot: 'Новый слот',
/** Автосборка сетки по профилю: предпросмотр плана и его создание. */ /** Автосборка сетки по профилю: предпросмотр плана и его создание. */
transfer: {
action: 'Импорт / экспорт',
title: 'Обмен конфигурацией сетки',
hint: 'Файл ссылается на группы и стыки по именам, поэтому переносится между каналами и установками. Запрос к ИИ никуда не отправляется — вы копируете его в свою модель и приносите ответ назад вкладкой импорта.',
tabs: { export: 'Экспорт', import: 'Импорт', ai: 'Запрос к ИИ' },
exportHint:
'Выгружает слои, слоты, правила и ссылки на группы и стыки. Годится и как бэкап перед экспериментом, и как образец для модели.',
exportAction: 'Скачать файл',
exported: 'Файл выгружен',
importHint:
'Загрузите файл или вставьте JSON — например ответ модели. Незнакомые ссылки не валят импорт: такие слоты пропускаются, и вы увидите их списком.',
pickFile: 'Выбрать файл',
replace: 'Снести существующие слоты',
pastePlaceholder: 'Вставьте JSON конфигурации сетки',
importAction: 'Загрузить',
imported: 'Создано слотов: {{slots}}, пропущено: {{skipped}}',
badJson: 'Это не похоже на JSON — проверьте, что скопирован весь ответ целиком.',
warnings: 'Что не удалось разобрать',
aiHint:
'Соберём запрос: параметры канала, ваши группы и библиотека, ваши референсы и пожелания, плюс схема ответа. Модель вернёт JSON, который загружается вкладкой импорта.',
references: 'Референс-каналы',
referencesPlaceholder: '2×2, Paramount Comedy',
referencesHint:
'Через запятую. На их ритм модель будет равняться — длина блоков, прайм, ночь.',
notes: 'Пожелания',
notesPlaceholder: 'Например: утром детское, вечером кино, ночью — взрослый блок',
buildPrompt: 'Собрать запрос',
copyPrompt: 'Скопировать',
copied: 'Запрос скопирован',
aiNext: 'Отправьте это в свою модель, а полученный JSON вставьте во вкладке «Импорт».',
},
generate: { generate: {
action: 'Собрать сетку', action: 'Собрать сетку',
title: 'Автосборка сетки', title: 'Автосборка сетки',
@@ -712,6 +743,11 @@ export const ru = {
timeWindow: 'Окно суток канала', timeWindow: 'Окно суток канала',
clearWindow: 'Сбросить', clearWindow: 'Сбросить',
timeWindowHint: 'Пусто — в любое время. Окно может переходить через полночь.', timeWindowHint: 'Пусто — в любое время. Окно может переходить через полночь.',
nearHour: 'Рядом с круглым часом',
nearHourUnit: 'мин до и после :00',
nearHourHint:
'0 — без привязки. Сигнал точного времени и джингл ставятся так: врезка играет только в окрестности :00, а не когда-нибудь в этом часе.',
badgeNearHour: '±{{minutes}} мин от часа',
badgeOnChange: 'на смене', badgeOnChange: 'на смене',
badgeInterval: 'раз в {{minutes}} мин', badgeInterval: 'раз в {{minutes}} мин',
}, },
@@ -792,6 +828,10 @@ export const ru = {
'next.year': 'Год следующего', 'next.year': 'Год следующего',
'next.genre': 'Жанр следующего', 'next.genre': 'Жанр следующего',
'next.time': 'Время старта следующего', 'next.time': 'Время старта следующего',
'tonight.title': 'Вечерняя программа',
'tonight.time': 'Время вечерней программы',
'tomorrow.title': 'Программа завтра вечером',
'tomorrow.time': 'Время программы завтра',
time: 'Время показа заставки', time: 'Время показа заставки',
date: 'Дата', date: 'Дата',
weekday: 'День недели', weekday: 'День недели',