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;
}
}