Implement template export/import endpoints and enhance grid generation logic
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:
@@ -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,
|
||||
|
||||
+38
-9
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+264
@@ -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>>;
|
||||
+100
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+239
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user