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.Layers;
using TeleWave.Application.Programming.Templates.Restore;
using TeleWave.Application.Programming.Templates.Transfer;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
@@ -80,6 +81,18 @@ public static class TemplateEndpoints
.MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid)
.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
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
@@ -224,6 +237,44 @@ public static class TemplateEndpoints
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(
Guid channelId,
Guid targetChannelId,
@@ -354,6 +405,10 @@ public sealed record UpdateTemplateBody(
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 UpdateLayerBody(
@@ -20,9 +20,15 @@ public sealed record BumperContext(
string? NextGenre = null,
/// <summary>Во сколько начнётся следующая программа (время канала).</summary>
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>
/// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30».
///
@@ -43,6 +49,10 @@ public static partial class BumperPlaceholders
"next.year",
"next.genre",
"next.time",
"tonight.title",
"tonight.time",
"tomorrow.title",
"tomorrow.time",
"time",
"date",
"weekday",
@@ -79,16 +89,33 @@ public static partial class BumperPlaceholders
/// по краям убираются: «СЕЙЧАС — {now.title}» без текущей программы должно стать «СЕЙЧАС»,
/// а не «СЕЙЧАС —».
///
/// Висящие предлоги («ДАЛЕЕ В») при этом остаются: отличить предлог от слова в тексте, который
/// пишет человек на любом языке, нельзя, а угадывать в эфире — хуже, чем оставить как есть.
/// Строка, в которой не подставился ни один плейсхолдер, исчезает целиком: она и писалась ради
/// данных, а «ДАЛЕЕ В» без времени — это не подпись, а мусор в кадре. Строки без плейсхолдеров
/// это не касается: они и есть постоянный текст.
/// </summary>
public static string Resolve(string text, BumperContext context)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
var tokens = 0;
var filled = 0;
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();
}
@@ -134,6 +161,10 @@ public static partial class BumperPlaceholders
"next.year" => context.NextYear?.ToString(Culture),
"next.genre" => context.NextGenre,
"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
.FromDateTime(context.LocalMoment.DateTime)
.ToString("HH:mm", Culture),
@@ -105,7 +105,10 @@ public sealed class RenderBumperPreviewCommandHandler(
samples.NextYear,
samples.NextGenre,
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<GroupMembershipCleaner>();
services.AddScoped<SlotWriter>();
services.AddScoped<GroupCatalog>();
services.AddScoped<GridPlanner>();
services.AddScoped<GroupExpander>();
services.AddScoped<BumperResolver>();
@@ -3,6 +3,7 @@ using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
@@ -20,20 +21,20 @@ internal sealed class BumperFacts
private readonly TimeSpan _offset;
private readonly Channel _channel;
private readonly IReadOnlyDictionary<Guid, ShowFact> _shows;
private readonly IReadOnlyDictionary<Guid, string> _slotTitles;
private readonly IReadOnlyDictionary<Guid, SlotFact> _slots;
private BumperFacts(
IReadOnlyList<PlannedItem> items,
Channel channel,
IReadOnlyDictionary<Guid, ShowFact> shows,
IReadOnlyDictionary<Guid, string> slotTitles
IReadOnlyDictionary<Guid, SlotFact> slots
)
{
_items = items;
_channel = channel;
_offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
_shows = shows;
_slotTitles = slotTitles;
_slots = slots;
}
public static async Task<BumperFacts> LoadAsync(
@@ -44,9 +45,17 @@ internal sealed class BumperFacts
CancellationToken cancellationToken
)
{
var announces = tokens.Any(token =>
token.StartsWith("tonight.") || token.StartsWith("tomorrow.")
);
// Имена нужны соседям заставки, а при анонсах — и всем программам ленты: анонсировать
// можно то, что стоит через несколько часов, а не только следующее.
var showIds = items
.Where(i => i.Kind == PlannedItemKind.Bumper)
.SelectMany(i => new[] { i.FromShowId, i.ToShowId })
.Where(i =>
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)
.Select(id => id!.Value)
.Distinct()
@@ -57,11 +66,14 @@ internal sealed class BumperFacts
? []
: 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>
@@ -85,12 +97,58 @@ internal sealed class BumperFacts
nextProgram is { } next
? TimeOnly.FromDateTime(next.StartsAtUtc.ToOffset(_offset).DateTime)
: null,
item.SlotId is { } slotId && _slotTitles.TryGetValue(slotId, out var title)
? title
: null
item.SlotId is { } slotId && _slots.TryGetValue(slotId, out var slot)
? slot.Title
: 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>
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,
IReadOnlyList<PlannedItem> items,
CancellationToken cancellationToken
@@ -211,9 +269,16 @@ internal sealed class BumperFacts
return await dbContext
.Slots.AsNoTracking()
.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(
string Name,
int? Year,
@@ -391,6 +391,15 @@ public sealed class GridScheduleGenerator(
var fallback = await LoadFallbackUnitsAsync(channel, template, cancellationToken);
// Чем добивается шов до круглой отметки: ролики и анонсы стыка канала по умолчанию.
var padJunction = BuildJunction(
template.DefaultJunctionId,
junctions,
elementsByGroup,
bumperTemplates,
Daypart.Day
);
return new PlanningInput(
channel.Id,
startUtc,
@@ -399,10 +408,32 @@ public sealed class GridScheduleGenerator(
fallback,
_segmentSeconds,
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>
private PlanningJunction? BuildJunction(
Guid? junctionId,
@@ -443,6 +474,7 @@ public sealed class GridScheduleGenerator(
conditions.TimeWindow is { } window
? new PlanningTimeWindow(window.From, window.To)
: null,
conditions.NearHourMinutes,
element.ChoiceKey,
element.ChoiceWeight,
element.BumperTemplateId,
@@ -24,6 +24,9 @@ public sealed class GenerateGridCommandHandler(
private const int MainPriority = 10;
private const int WeekendPriority = 20;
/// <summary>Праздники перекрывают и будни, и выходные: 1 января — это не «обычное воскресенье».</summary>
private const int SeasonPriority = 30;
public async Task<Result<GenerateGridResultDto>> Handle(
GenerateGridCommand command,
CancellationToken cancellationToken
@@ -53,7 +56,7 @@ public sealed class GenerateGridCommandHandler(
var created = 0;
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);
if (!applied.IsSuccess)
return Result.Failure<GenerateGridResultDto>(applied.Error);
@@ -86,17 +89,38 @@ public sealed class GenerateGridCommandHandler(
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) =
kind == GridPlanLayer.Weekend
? (GridPlanner.WeekendLayerName, WeekendPriority)
: (GridPlanner.MainLayerName, MainPriority);
var (name, priority) = kind switch
{
GridPlanLayer.Weekend => (GridPlanner.WeekendLayerName, WeekendPriority),
GridPlanLayer.Season => (season?.Name ?? "Праздники", SeasonPriority),
_ => (GridPlanner.MainLayerName, MainPriority),
};
return template.Layers.FirstOrDefault(l => !l.IsBackground && l.Name == name)
?? template.AddLayer(name, priority);
if (template.Layers.FirstOrDefault(l => !l.IsBackground && l.Name == name) is { } existing)
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) =>
new(
slot.Title,
@@ -113,6 +137,11 @@ public sealed class GenerateGridCommandHandler(
OverflowPolicy.ContinueNext,
slot.IsAnchor,
Slot.DefaultMaxDriftMinutes,
null
// Округление старта до четверти часа: программа передач не начинается в 19:47, а разницу
// добирает рекламный шов. Мягкое — если добирать пришлось бы дольше допуска, пропустится.
SnapMinutes
);
/// <summary>Шаг округления стартов у собранных слотов — та же решётка, что и у длин.</summary>
private const int SnapMinutes = 15;
}
@@ -17,6 +17,9 @@ public enum GridPlanLayer
{
Main = 0,
Weekend = 1,
/// <summary>Праздничная сетка: слой поверх основного, действующий только в свои даты.</summary>
Season = 2,
}
/// <summary>
@@ -21,11 +21,7 @@ namespace TeleWave.Application.Programming.Templates.Generate;
/// на настоящем ТВ; жанр не занимает подряд две полосы. Подбор при этом остаётся объяснимым —
/// жёсткий отсев по рейтингу, дальше баллы.
/// </summary>
public sealed class GridPlanner(
IAppDbContext dbContext,
DynamicGroupResolver dynamicResolver,
GroupElementResolver elementResolver
)
public sealed class GridPlanner(IAppDbContext dbContext, GroupCatalog groups)
{
public const string MainLayerName = "Основная сетка";
public const string WeekendLayerName = "Выходные";
@@ -34,7 +30,19 @@ public sealed class GridPlanner(
private const int MinSlotMinutes = 15;
/// <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>
private const int FeatureLengthMinutes = 60;
@@ -64,14 +72,14 @@ public sealed class GridPlanner(
if (template is null)
return Result.Failure<GridPlan>(ChannelErrors.TemplateNotFound);
var groups = await LoadGroupsAsync(cancellationToken);
if (groups.Count == 0)
var candidates = await groups.LoadAsync(cancellationToken);
if (candidates.Count == 0)
return Result.Failure<GridPlan>(TemplateErrors.NoGroupsToGenerate);
var profile = GridProfiles.Get(profileKind);
var run = new PlanRun(
profile,
groups,
candidates,
channel.DayStartTime,
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 everyDaySpan = EveryDaySpan(mode, profile);
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);
notes.AddRange(run.WeeklyChecks());
notes.AddRange(AudienceChecks(slots, run));
@@ -114,7 +130,7 @@ public sealed class GridPlanner(
new GridPlan(
profile,
mode,
slots,
[.. slots, .. season],
notes,
mode == GridGenerationMode.Rebuild ? existing.Count : 0,
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>
private static int EveryDaySpan(GridGenerationMode mode, GridProfile profile) =>
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);
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)
@@ -474,12 +509,18 @@ public sealed class GridPlanner(
private static int RepeatMinutes(GridBand band) =>
band.BlockMinutes > 0 ? band.BlockMinutes : 120;
private static int Round(int minutes) =>
Math.Max(SlotStepMinutes, minutes / SlotStepMinutes * SlotStepMinutes);
/// <summary>
/// Вверх до шага решётки: слот не должен быть короче своего контента, иначе блок перелезет
/// через объявленное время. Разницу между контентом и отметкой занимает рекламный шов.
/// </summary>
private static int Round(int minutes, int step = SlotStepMinutes) =>
Math.Max(step, (int)Math.Ceiling(minutes / (double)step) * step);
/// <summary>Средняя длина единицы группы, округлённая до пяти минут (0 — длительностей нет).</summary>
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>
/// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной
@@ -503,11 +544,18 @@ public sealed class GridPlanner(
if (ranked.Count == 0)
return null;
if (!band.Rotate || target.Weekday is not { } weekday)
return ranked[0];
var day = Array.IndexOf(GridCoverage.Week, weekday);
return ranked[Math.Max(0, day) % ranked.Count];
if (band.Rotate && target.Weekday is { } weekday)
{
// Ротация — это сдвиг по кругу, а не другой порядок: лучший кандидат остаётся лучшим,
// просто в понедельник он первый, во вторник второй.
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)
@@ -546,6 +594,11 @@ public sealed class GridPlanner(
if (run.FollowsSameGenre(group))
score -= 25;
// Та же группа встык — уже не ритм, а признак нехватки контента: штраф тяжелее жанрового,
// но не запрет: когда в библиотеке одна группа, ставить всё равно нечего.
if (run.FollowsSameGroup(group))
score -= 90;
if (run.Rules?.MaxGenreSharePercent is { } maxShare && run.GenreShare(group) >= maxShare)
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>
/// Готовый профиль сетки: как выглядят вещательные сутки у канала такого типа. Профили списаны
/// с реальных каналов намеренно — «как у 2×2» админ проверяет по памяти, а «универсальный
@@ -67,5 +74,7 @@ public sealed record GridProfile(
string Reference,
string Description,
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>
public static class GridProfiles
{
// Праздничная сетка одна и та же у всех профилей, поэтому она навешивается здесь, а не
// повторяется шесть раз в описаниях: профиль отличают будни, а не Новый год.
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) =>
All.FirstOrDefault(p => p.Kind == kind) ?? All[0];
@@ -48,6 +60,67 @@ public static class GridProfiles
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() =>
new(
GridProfileKind.Mixed,
@@ -84,7 +157,8 @@ public static class GridProfiles
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 120,
UnitsPerBlock: 3
UnitsPerBlock: 3,
Rotate: true
),
new(
At(18),
@@ -196,7 +270,8 @@ public static class GridProfiles
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 150,
UnitsPerBlock: 5
UnitsPerBlock: 5,
Rotate: true
),
new(
At(19),
@@ -251,8 +326,8 @@ public static class GridProfiles
GridProfileKind.Sitcom,
"Ситкомы",
"как у Paramount Comedy",
"Полосы получасовых серий по 3–4 подряд весь день, комедийная полнометражка вечером, "
+ "ночью взрослый юмор.",
"Получасовые серии парами весь день — между парами рекламный шов, как в настоящем "
+ "ситкомовском эфире. Вечером комедийная полнометражка, ночью взрослый юмор.",
[
new(
At(6),
@@ -261,8 +336,8 @@ public static class GridProfiles
"Утренние ситкомы",
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 90,
UnitsPerBlock: 3
BlockMinutes: 60,
UnitsPerBlock: 2
),
new(
At(12),
@@ -271,8 +346,9 @@ public static class GridProfiles
"Дневные ситкомы",
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 90,
UnitsPerBlock: 3
BlockMinutes: 60,
UnitsPerBlock: 2,
Rotate: true
),
new(
At(18),
@@ -281,8 +357,8 @@ public static class GridProfiles
"Вечерние ситкомы",
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 120,
UnitsPerBlock: 4
BlockMinutes: 60,
UnitsPerBlock: 2
),
new(
At(22),
@@ -315,8 +391,8 @@ public static class GridProfiles
"Дневные ситкомы",
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 120,
UnitsPerBlock: 4
BlockMinutes: 60,
UnitsPerBlock: 2
),
new(
At(20),
@@ -366,7 +442,8 @@ public static class GridProfiles
DayBlock,
BlockMinutes: 240,
Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 2
CooldownDays: 2,
Rotate: true
),
new(
At(18),
@@ -506,7 +583,8 @@ public static class GridProfiles
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 90,
UnitsPerBlock: 3
UnitsPerBlock: 3,
Rotate: true
),
new(
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 _baselineMinutes;
private Guid? _lastGenreId;
private Guid? _lastGroupId;
private readonly List<PlanNote> _notes = [];
private readonly HashSet<string> _seen = [];
@@ -73,6 +74,7 @@ public sealed class PlanRun(
_dayGenreMinutes = fromBaseline ? new Dictionary<Guid, int>(_baselineGenre) : [];
_dayMinutes = fromBaseline ? _baselineMinutes : 0;
_lastGenreId = null;
_lastGroupId = null;
}
/// <summary>
@@ -100,6 +102,12 @@ public sealed class PlanRun(
public bool FollowsSameGenre(GroupCandidate group) =>
group.PrimaryGenreId is { } genreId && _lastGenreId == genreId;
/// <summary>
/// Та же группа, что и в предыдущем слоте. Два блока подряд из одной группы читаются как один
/// длинный: в сетке это выглядит не ритмом канала, а нехваткой контента.
/// </summary>
public bool FollowsSameGroup(GroupCandidate group) => _lastGroupId == group.Id;
// ── Неделя ───────────────────────────────────────────────────────────
/// <summary>Единиц группы, ещё не разобранных под слоты этой недели (может уйти в минус).</summary>
@@ -116,6 +124,7 @@ public sealed class PlanRun(
_weekAirings[group.Id] = _weekAirings.GetValueOrDefault(group.Id) + airingsPerWeek;
_dayUsed[group.Id] = _dayUsed.GetValueOrDefault(group.Id) + 1;
_dayMinutes += minutes;
_lastGroupId = group.Id;
if (group.PrimaryGenreId is not { } genreId)
return;
@@ -18,7 +18,9 @@ public sealed record JunctionConditions(
/// <summary>Только в это окно суток канала (null — в любое время).</summary>
JunctionTimeWindow? TimeWindow = null,
/// <summary>Вероятность показа в процентах; 100 — всегда.</summary>
int Chance = 100
int Chance = 100,
/// <summary>Ставить только в пределах ±N минут от круглого часа (0 — без привязки).</summary>
int NearHourMinutes = 0
)
{
private static readonly JsonSerializerOptions Options = new()
@@ -84,5 +84,9 @@ public sealed class UpdateJunctionElementCommandValidator
RuleFor(x => x.Input.Conditions!.Chance)
.InclusiveBetween(0, 100)
.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;
}
if (!NearHour(element, cursor, placement.ChannelOffset))
return false;
// Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации,
// поэтому пересборка хвоста не перетасовывает врезки на каждое применение.
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>
/// Схлопывает развилки: из врезок с одной меткой остаётся одна, выбранная по весам. Позиция
/// развилки в цепочке — позиция её первого участника.
@@ -111,6 +111,8 @@ public sealed record PlanningJunctionElement(
int Chance = 100,
/// <summary>Окно времени суток, вне которого врезка не ставится (null — всегда).</summary>
PlanningTimeWindow? TimeWindow = null,
/// <summary>Допуск от круглого часа в минутах; 0 — врезка не привязана к часу.</summary>
int NearHourMinutes = 0,
/// <summary>Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам.</summary>
string? ChoiceKey = null,
int ChoiceWeight = 1,
@@ -149,12 +151,32 @@ public sealed record PlanningInput(
/// короче одной серии, добор до якоря, пустой слот-повтор. Отдельным списком, потому что
/// в основном перекрытых слотов нет — там побеждает слой с большим приоритетом.
/// </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<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>
public sealed record PlannedItem(
Guid MediaAssetId,
@@ -42,8 +42,12 @@ public static class SchedulePlanner
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
public Guid? PreviousShowId { get; set; }
/// <summary>С какой единицы аварийного запаса продолжать — общий на прогон, см. NextFallbackUnit.</summary>
public int FallbackCursor { get; set; }
/// <summary>Аварийный запас: кольцевой перебор, общий на прогон.</summary>
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>
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)
{
@@ -126,7 +130,7 @@ public static class SchedulePlanner
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
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;
cursor = afterFill;
}
@@ -392,11 +396,63 @@ public static class SchedulePlanner
/// поломкой и остаётся последней ступенью.
/// </summary>
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 until,
PlanningRun run,
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 guard = 0;
@@ -433,7 +489,7 @@ public static class SchedulePlanner
run.PreviousShowId = pick.Unit.ShowId;
}
return FillWithFallback(cursor, until, run, slotId);
return cursor;
}
/// <summary>
@@ -450,15 +506,12 @@ public static class SchedulePlanner
PlanTrace? trace = null
)
{
if (run.Input.FallbackUnits.Count == 0 || until <= from)
return from;
var cursor = from;
var guard = 0;
while (cursor < until && guard++ < IterationBackstop)
{
if (NextFallbackUnit(run, until - cursor) is not { } unit)
if (run.Fallback.Next(until - cursor) is not { } unit)
break;
run.Items.Add(
@@ -466,8 +519,8 @@ public static class SchedulePlanner
unit.MediaAssetId,
cursor,
cursor + unit.Duration,
// Фон — это тоже программы из группы, и в эфирной ленте они обязаны быть
// подписаны: без шоу зритель и админ видят на их месте прочерк.
// Аварийная группа — это тоже программы, и в ленте они обязаны быть подписаны:
// без шоу зритель и админ видят на их месте прочерк. Филлер канала шоу не имеет.
unit.ShowId == Guid.Empty
? null
: unit.ShowId,
@@ -483,33 +536,6 @@ public static class SchedulePlanner
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>
private static Guid? CollectionOf(PlanningElement element) =>
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);
// Ни следующей программы, ни номера канала: строка схлопывается, осиротевший разделитель уходит.
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("{channel} {channel.number}", empty));
// А висящий предлог остаётся: отличить его от слова в чужом тексте нельзя.
Assert.Equal("ДАЛЕЕ В", BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time}", empty));
// Строка, где не подставился ни один плейсхолдер, исчезает целиком: «ДАЛЕЕ В» без времени —
// это мусор в кадре, а не подпись.
Assert.Equal("", BumperPlaceholders.Resolve("ДАЛЕЕ В {next.time}", empty));
// А постоянный текст без плейсхолдеров остаётся всегда.
Assert.Equal("СЕЙЧАС", BumperPlaceholders.Resolve("СЕЙЧАС", empty));
}
[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()));
}
[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]
public async Task Resolve_IgnoresReservationsWithoutTemplate()
{
@@ -107,7 +107,9 @@ public class GenerateGridTests
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));
private static async Task<GridPlan> PlanAsync(
@@ -149,10 +151,15 @@ public class GenerateGridTests
Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
// Полосы без ротации свободны во все семь дней — они идут «каждый день», а не семью копиями.
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)
);
// Прайм (с 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(
plan.Slots.Where(s => s.Start == new TimeOnly(20, 0)),
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]
public async Task PrimeGetsFeature_AndMorningGetsSeries()
{
@@ -322,10 +358,117 @@ public class GenerateGridTests
}
[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 series = Playable("Сериал", ShowKind.Series, 60, 25);
var (channelId, _) = await SeedChannelAsync(
@@ -342,8 +485,12 @@ public class GenerateGridTests
morning,
slot =>
{
// Слот вмещает ровно столько серий, сколько в нём заявлено, и с точностью до пяти минут.
Assert.Equal(slot.DurationMinutes, slot.BlockValue * 25);
var content = 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);
}
);
@@ -116,7 +116,10 @@ public class GridPlanPreviewTests
await using var db = fixture.New();
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(
new PreviewGeneratedGridQuery(
channelId,
@@ -143,7 +146,10 @@ public class GridPlanPreviewTests
await using var db = fixture.New();
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(
new PreviewGeneratedGridQuery(
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,
string? choiceKey = null,
int choiceWeight = 1,
int poolSize = 8
int poolSize = 8,
int nearHourMinutes = 0
) =>
new(
Guid.NewGuid(),
@@ -55,6 +56,7 @@ public class JunctionFillerTests
minMinutesBetween,
chance,
window,
nearHourMinutes,
choiceKey,
choiceWeight
);
@@ -234,6 +236,58 @@ public class JunctionFillerTests
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]
public void Choice_PlacesExactlyOneOfTheFork()
{
@@ -75,7 +75,9 @@ public class SchedulePlannerTests
DateTimeOffset? start = null,
int horizonHours = 6,
IReadOnlyList<PlanningUnit>? fallback = null,
IReadOnlyList<PlanningSlot>? background = null
IReadOnlyList<PlanningSlot>? background = null,
IReadOnlyList<PadUnit>? pad = null,
TimeSpan maxPad = default
) =>
new(
Guid.NewGuid(),
@@ -85,9 +87,13 @@ public class SchedulePlannerTests
fallback ?? [Unit(1, Guid.NewGuid(), 0)],
SegmentSeconds: 2,
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) =>
TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways());
@@ -350,6 +356,72 @@ public class SchedulePlannerTests
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]
public void Background_PlaysInLeftoverInsteadOfEmergencyFiller()
{
+52 -3
View File
@@ -454,7 +454,8 @@ JunctionElement
"minMinutesBetween": 30,
"dayparts": ["prime", "day"],
"timeWindow": { "from": "20:00", "to": "23:00" },
"chance": 40
"chance": 40,
"nearHourMinutes": 2
}
```
@@ -465,6 +466,10 @@ JunctionElement
стыков ставим анонс». `minMinutesBetween` считается **по конкретной врезке**, а не по её виду:
две рекламные врезки в разных стыках — это разные ограничения, общий счётчик на вид склеил бы их.
`nearHourMinutes` — допуск от круглого часа по времени канала. Сигнал точного времени и джингл тем
и узнаются, что звучат в :00; врезка с допуском ставится только в его окрестности, а не «когда-нибудь
в этом часе». Ноль — привязки нет.
**Развилка** (`choiceKey`) — несколько врезок с одной меткой, из которых играет одна, выбранная по
весам: «иногда заставка, иногда короткий рекламный блок». Врезки одной развилки обязаны занимать
непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия
@@ -565,11 +570,20 @@ BumperLine
| `{now.episode}` `{next.episode}` | «с5э12» либо название серии |
| `{next.year}` `{next.genre}` | из метаданных |
| `{next.time}` | во сколько начнётся следующая программа |
| `{tonight.title}` `{tonight.time}` | первая программа прайма этих вещательных суток |
| `{tomorrow.title}` `{tomorrow.time}` | то же для следующих суток |
| `{time}` `{date}` `{weekday}` | момент показа заставки во времени канала |
| `{slot}` | название слота — «Вечернее кино» |
Неизвестное значение подставляется пустым, строка со схлопнувшимися пробелами не рисуется.
Неизвестный плейсхолдер — **ошибка валидации при сохранении**, а не сюрприз в эфире.
Анонсы смотрят **вперёд по собранной ленте** и только на программы, которые ещё не начались:
«сегодня в 20:00 — Терминатор», сказанное в девять вечера, хуже, чем молчание. Ночь до
`dayStartTime` относится к предыдущим вещательным суткам, иначе «сегодня вечером» в час ночи
означало бы уже следующий вечер.
Неизвестное значение подставляется пустым. Строка, в которой **не подставился ни один**
плейсхолдер, не рисуется целиком: она писалась ради данных, и «ДАЛЕЕ В» без времени — это не
подпись, а мусор в кадре. Постоянного текста без плейсхолдеров это не касается. Неизвестный
плейсхолдер — **ошибка валидации при сохранении**, а не сюрприз в эфире.
Цена гибкости — кэш: `{time}` и `{date}` делают каждый показ уникальным, а рендер это ffmpeg
на несколько секунд, помноженный на недельный горизонт. Запрещать нечего, но редактор обязан
@@ -1034,6 +1048,15 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
не лучшего кандидата, а следующего по кругу: иначе неделя выглядит одним повторяющимся днём.
Остальные полосы остаются слотами «каждый день» — сетка должна читаться.
**Праздничная сетка — сезонный слой.** В режиме «с нуля» генератор строит ещё один слой поверх
основного и выходных, с ежегодной применимостью (25 декабря — 8 января): днём марафон, вечером кино.
Он один и тот же у всех профилей намеренно — в праздники каналы сходятся к одному ритму, и различает
их библиотека, а не сетка. Слоты слоя идут «каждый день»: период занимает несколько дат подряд, и
расписывать их по дням недели значило бы получить сетку, зависящую от того, на какой день выпало
31 декабря. Свой разбор и свой бюджет: праздничная неделя идёт **вместо** обычной, а не вдобавок,
и обеднять ради неё обычную нельзя. Даты и содержимое слоя дальше правятся как у любого другого —
повторная генерация применимость уже созданного слоя не переписывает.
**Жанр не занимает две полосы подряд**, а при заданном `MaxGenreSharePercent` — и больше своей
доли суток. Это тот же потолок, который потом проверяет пост-проверка из 3.8, но соблюдаемый
на раскладке, а не констатируемый после.
@@ -1045,6 +1068,32 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
с тем, что потом скажут проверки сетки. Эфир генерация не двигает — как любая правка сетки, она
только поднимает ревизию шаблона, а хвост пересобирает кнопка применения.
### 6.10. Обмен конфигурацией сетки
Сетка переносится файлом: выгрузка, загрузка и запрос к ИИ — три вкладки одного диалога.
**Ссылки в файле именные.** Группа, стык и аварийная группа записываются названиями, а не
идентификаторами: файл едет на другой канал и на другую установку, где тех же GUID нет, а сетку для
импорта пишет в том числе языковая модель — идентификаторы она может только выдумать. Импорт
разрешает имена сам; незнакомое имя не валит загрузку, а становится замечанием, и админ видит
списком, чего в библиотеке не нашлось. Тем же способом пропускаются слоты, которые не проходят
проверки (перекрытие, нет группы у содержательного слота).
Слой берётся по имени, поэтому повторная загрузка того же файла даёт ту же сетку, а не
«Основная сетка (2)». Режим «снести существующие слоты» работает как пересборка из 6.9. Эфир
импорт не двигает: он помечает шаблон изменённым, а хвост пересобирает кнопка применения.
**Запрос к ИИ собирается на сервере и никуда не отправляется.** Ключей внешних сервисов проект
не хранит, и заводить их ради одной кнопки не нужно: админ вводит референс-каналы («как у 2×2»)
и пожелания своими словами, получает готовый текст, копирует его в ту модель, которой пользуется,
и приносит ответ назад вкладкой импорта.
В запрос входят параметры канала, доступные группы с их ёмкостью и средней длиной единицы (те же
числа, по которым раскладывает автосборка, — считает их общий `GroupCatalog`), библиотека справкой,
имена стыков и заставок, правила раскладки и схема ответа. Схема лежит рядом с форматом
(`GridPromptText` в одной папке с `GridConfig`), а не в шаблоне на фронте: разойдись она с импортом
— ответ модели пришлось бы чинить руками.
---
## 7. Порядок реализации
@@ -20,6 +20,10 @@ export const PLACEHOLDERS: PlaceholderSample[] = [
{ token: 'next.year', sample: '1991' },
{ token: 'next.genre', sample: 'Боевик' },
{ 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: 'date', sample: '6 апреля' },
{ 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]))
/** Как строка будет выглядеть в кадре: подстановка образцами + схлопывание лишних пробелов. */
/**
* Как строка будет выглядеть в кадре. Зеркало серверного `BumperPlaceholders.Resolve`: пустые
* значения схлопываются вместе с осиротевшими разделителями, а строка, где не подставился ни один
* плейсхолдер, исчезает целиком — «ДАЛЕЕ В» без времени это не подпись, а мусор в кадре.
*/
export function resolveSample(text: string) {
return text
.replace(TOKEN_PATTERN, (_, token: string) => SAMPLES.get(token) ?? '')
let tokens = 0
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, ' ')
.trim()
.replace(/^[—–\-:·,;/]+|[—–\-:·,;/]+$/g, '')
.trim()
}
/** Плейсхолдеры строки, которых нет в списке допустимых, — их сервер отвергнет при сохранении. */
@@ -164,6 +164,7 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {
{tab === 'grid' && (
<GridTab
channelId={channelId}
channelName={channel.name}
template={template}
templateError={templateError}
onChanged={invalidate}
@@ -7,7 +7,10 @@ import type {
CreatedIdResponse,
EntryTraceDto,
GenerateGridResultDto,
GridConfig,
GridGenerationMode,
GridImportResultDto,
GridPromptDto,
GridPlanDto,
GridProfileDto,
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) {
return apiRequest<CopyTemplateResultDto>(
@@ -1,5 +1,5 @@
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 { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
@@ -22,6 +22,7 @@ import {
} from '../api'
import { toTime } from '../lib/format'
import { GenerateGridDialog } from './GenerateGridDialog'
import { GridTransferDialog } from './GridTransferDialog'
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './ScheduleGrid'
import { SlotInspector, type SlotDraft } from './SlotInspector'
@@ -35,12 +36,14 @@ import { TemplatePreview } from './TemplatePreview'
*/
export function GridTab({
channelId,
channelName,
template,
templateError,
onChanged,
onError,
}: Readonly<{
channelId: string
channelName: string
template: ScheduleTemplateDto | undefined
templateError: unknown
onChanged: () => void
@@ -52,6 +55,7 @@ export function GridTab({
const [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
const [generating, setGenerating] = useState(false)
const [transferring, setTransferring] = useState(false)
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
@@ -308,6 +312,10 @@ export function GridTab({
<Wand2 className="h-4 w-4" />
{t('admin.channels.generate.action')}
</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>
<Input
type="date"
@@ -378,6 +386,16 @@ export function GridTab({
</CardContent>
</Card>
{transferring && (
<GridTransferDialog
channelId={channelId}
channelName={channelName}
onClose={() => setTransferring(false)}
onImported={onChanged}
onError={onError}
/>
)}
{generating && (
<GenerateGridDialog
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)
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 && c.nearHourMinutes > 0)
parts.push(t('admin.junctions.badgeNearHour', { minutes: c.nearHourMinutes }))
if (c?.dayparts?.length)
parts.push(c.dayparts.map((d) => t(`admin.channels.dayparts.${d}`)).join('/'))
return parts.join(' · ')
@@ -26,6 +26,7 @@ const DEFAULT_CONDITIONS: JunctionConditions = {
dayparts: null,
timeWindow: null,
chance: 100,
nearHourMinutes: 0,
}
function toBody(element: JunctionElementDto): JunctionElementBody {
@@ -274,6 +275,31 @@ export function JunctionElementDialog({
</div>
<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">
<Label>{t('admin.junctions.dayparts')}</Label>
<div className="flex flex-wrap gap-2">
+64 -1
View File
@@ -589,6 +589,8 @@ export type JunctionConditions = {
dayparts?: Daypart[] | null
timeWindow?: JunctionTimeWindow | null
chance: number
/** Ставить только в пределах ±N минут от круглого часа; 0 — без привязки. */
nearHourMinutes: number
}
export type JunctionElementDto = {
@@ -762,7 +764,7 @@ export type GridProfileDto = {
/** Строка предпросмотра: будущий слот до того, как он создан. */
export type GridPlanSlotDto = {
layer: 'Main' | 'Weekend'
layer: 'Main' | 'Weekend' | 'Season'
weekday: number | null
start: string
durationMinutes: number
@@ -791,6 +793,67 @@ export type GridPlanDto = {
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 }
+41
View File
@@ -429,6 +429,38 @@ export const en = {
addSlotHere: 'Add slot',
newSlot: 'New slot',
/** 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: {
action: 'Build grid',
title: 'Grid auto-build',
@@ -712,6 +744,11 @@ export const en = {
timeWindow: 'Channel time window',
clearWindow: 'Clear',
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',
badgeInterval: 'once per {{minutes}} min',
},
@@ -792,6 +829,10 @@ export const en = {
'next.year': 'Next show year',
'next.genre': 'Next show genre',
'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',
date: 'Date',
weekday: 'Weekday',
+40
View File
@@ -430,6 +430,37 @@ export const ru = {
addSlotHere: 'Добавить слот',
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: {
action: 'Собрать сетку',
title: 'Автосборка сетки',
@@ -712,6 +743,11 @@ export const ru = {
timeWindow: 'Окно суток канала',
clearWindow: 'Сбросить',
timeWindowHint: 'Пусто — в любое время. Окно может переходить через полночь.',
nearHour: 'Рядом с круглым часом',
nearHourUnit: 'мин до и после :00',
nearHourHint:
'0 — без привязки. Сигнал точного времени и джингл ставятся так: врезка играет только в окрестности :00, а не когда-нибудь в этом часе.',
badgeNearHour: '±{{minutes}} мин от часа',
badgeOnChange: 'на смене',
badgeInterval: 'раз в {{minutes}} мин',
},
@@ -792,6 +828,10 @@ export const ru = {
'next.year': 'Год следующего',
'next.genre': 'Жанр следующего',
'next.time': 'Время старта следующего',
'tonight.title': 'Вечерняя программа',
'tonight.time': 'Время вечерней программы',
'tomorrow.title': 'Программа завтра вечером',
'tomorrow.time': 'Время программы завтра',
time: 'Время показа заставки',
date: 'Дата',
weekday: 'День недели',