Refactor GenerateGridCommandHandler and GridPlanner for improved slot management and readability
ci / build-backend (push) Successful in 2m45s
ci / build-frontend (push) Successful in 1m11s
ci / tests (push) Successful in 2m45s
ci / sonar (push) Successful in 6m33s

Updated the GenerateGridCommandHandler to streamline slot removal during grid generation by introducing a new RemoveAllSlots method. Enhanced the GridPlanner class by refactoring the FillDay method to utilize a SlotTarget struct for better clarity and organization. These changes improve code maintainability and readability while ensuring efficient slot handling across grid generation processes.
This commit is contained in:
Leonid Pershin
2026-07-27 09:08:20 +03:00
parent f22aee4685
commit b8d6b6aad2
6 changed files with 155 additions and 116 deletions
@@ -48,12 +48,7 @@ public sealed class GenerateGridCommandHandler(
if (template is null)
return Result.Failure<GenerateGridResultDto>(ChannelErrors.TemplateNotFound);
var removed = 0;
if (command.Mode == GridGenerationMode.Rebuild)
foreach (var layer in template.Layers)
foreach (var slot in layer.Slots.ToList())
if (layer.RemoveSlot(slot.Id))
removed++;
var removed = command.Mode == GridGenerationMode.Rebuild ? RemoveAllSlots(template) : 0;
var created = 0;
foreach (var slot in plan.Slots)
@@ -74,6 +69,23 @@ public sealed class GenerateGridCommandHandler(
return Result.Success(new GenerateGridResultDto(created, removed));
}
/// <summary>Снимает все слоты шаблона и возвращает их число — режим «собрать с нуля».</summary>
private static int RemoveAllSlots(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;
}
private static GridLayer EnsureLayer(ScheduleTemplate template, GridPlanLayer kind)
{
var (name, priority) =
@@ -110,15 +110,21 @@ public sealed class GridPlanner(
private static List<PlannedSlot> BuildWeek(PlanRun run)
{
var wholeDay = new List<(int From, int To)> { (0, GridCoverage.MinutesInDay) };
var slots = FillDay(run.Profile.Weekdays, wholeDay, null, GridPlanLayer.Main, run);
var slots = FillDay(
run.Profile.Weekdays,
wholeDay,
new SlotTarget(null, GridPlanLayer.Main),
run
);
if (run.Profile.Weekend.Count == 0)
return slots;
foreach (var weekday in new[] { 6, 0 })
slots.AddRange(
FillDay(run.Profile.Weekend, wholeDay, weekday, GridPlanLayer.Weekend, run)
);
{
var target = new SlotTarget(weekday, GridPlanLayer.Weekend);
slots.AddRange(FillDay(run.Profile.Weekend, wholeDay, target, run));
}
return slots;
}
@@ -139,9 +145,8 @@ public sealed class GridPlanner(
foreach (var weekday in GridCoverage.Week)
{
var gaps = GridCoverage.Gaps(existing, weekday, run.DayStart);
foreach (
var slot in FillDay(run.Profile.Weekdays, gaps, weekday, GridPlanLayer.Main, run)
)
var target = new SlotTarget(weekday, GridPlanLayer.Main);
foreach (var slot in FillDay(run.Profile.Weekdays, gaps, target, run))
entries.Add((weekday, slot));
}
@@ -168,8 +173,7 @@ public sealed class GridPlanner(
private static List<PlannedSlot> FillDay(
IReadOnlyList<GridBand> bands,
IReadOnlyList<(int From, int To)> free,
int? weekday,
GridPlanLayer layer,
SlotTarget target,
PlanRun run
)
{
@@ -178,21 +182,25 @@ public sealed class GridPlanner(
var used = new Dictionary<Guid, int>();
var slots = new List<PlannedSlot>();
foreach (var band in bands)
foreach (var (bandFrom, bandTo) in BandPieces(band, run.DayStart))
foreach (var (freeFrom, freeTo) in free)
{
var from = Math.Max(bandFrom, freeFrom);
var to = Math.Min(bandTo, freeTo);
if (to - from < MinSlotMinutes)
continue;
// Что реально предстоит застроить: полосы профиля, пересечённые со свободным временем.
var pieces =
from band in bands
from piece in BandPieces(band, run.DayStart)
from gap in free
let start = Math.Max(piece.From, gap.From)
let end = Math.Min(piece.To, gap.To)
where end - start >= MinSlotMinutes
select (Band: band, From: start, To: end);
slots.AddRange(FillPiece(band, from, to, weekday, layer, run, used));
}
foreach (var piece in pieces)
slots.AddRange(FillPiece(piece.Band, piece.From, piece.To, target, run, used));
return [.. slots.OrderBy(s => GridCoverage.OffsetInDay(s.Start, run.DayStart))];
}
/// <summary>Куда ложится слот: день недели и слой. Пара ходит вместе через всю раскладку.</summary>
private readonly record struct SlotTarget(int? Weekday, GridPlanLayer Layer);
/// <summary>
/// Полоса в смещениях от начала вещательных суток. Начало суток канала произвольно, поэтому
/// полоса может перевалить через их конец — тогда она разрезается на два куска.
@@ -217,8 +225,7 @@ public sealed class GridPlanner(
GridBand band,
int from,
int to,
int? weekday,
GridPlanLayer layer,
SlotTarget target,
PlanRun run,
Dictionary<Guid, int> used
)
@@ -250,7 +257,7 @@ public sealed class GridPlanner(
// Якорь — только на первом слоте прайма: жёсткий старт у каждого слота подряд заставил
// бы генератор крутить фон перед каждым из них.
var anchor = cursor == from && band.Daypart == Daypart.Prime;
var slot = Compose(band, weekday, layer, cursor, duration, anchor, run, used);
var slot = Compose(band, target, cursor, duration, anchor, run, used);
if (slot is not null)
yield return slot;
@@ -260,8 +267,7 @@ public sealed class GridPlanner(
private static PlannedSlot? Compose(
GridBand band,
int? weekday,
GridPlanLayer layer,
SlotTarget target,
int offset,
int duration,
bool anchor,
@@ -295,7 +301,7 @@ public sealed class GridPlanner(
}
used[group.Id] = used.GetValueOrDefault(group.Id) + 1;
run.WarnIfThin(band, group, weekday);
run.WarnIfThin(band, group, target.Weekday);
var blockMode = band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot;
return New(
@@ -316,8 +322,8 @@ public sealed class GridPlanner(
RepeatSource? repeat
) =>
new(
layer,
weekday,
target.Layer,
target.Weekday,
start,
duration,
title,
@@ -393,52 +399,48 @@ public sealed class GridPlanner(
cancellationToken
);
var units = 0;
var duration = TimeSpan.Zero;
var seriesUnits = 0;
var singleUnits = 0;
ShowAudience? strictest = null;
foreach (var element in composition)
{
if (!info.TryGetValue((element.Kind, element.Id), out var resolved))
continue;
units += resolved.UnitCount;
duration += resolved.TotalDuration;
// У коллекции своего типа нет: франшиза — это почти всегда полнометражки.
if (resolved.ShowKind == ShowKind.Series)
seriesUnits += resolved.UnitCount;
else
singleUnits += resolved.UnitCount;
if (
resolved.Audience is { } audience
&& (strictest is null || audience > strictest)
)
strictest = audience;
if (Describe(group, composition, info) is { } candidate)
candidates.Add(candidate);
}
// Группа без готовых единиц в эфир не годится: слот встал бы, а место закрыл фон.
if (units == 0)
continue;
return candidates;
}
candidates.Add(
new GroupCandidate(
/// <summary>
/// Сводит вычисленный состав к тому, что нужно раскладке: сколько эфира группа даёт, какой она
/// в основном природы и насколько строгий в ней контент. Группа без готовых единиц не годится
/// вовсе — слот по ней встал бы, а место закрыл фон.
/// </summary>
private static GroupCandidate? Describe(
Group group,
IReadOnlyList<ResolvedElement> composition,
IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo> info
)
{
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);
var strictest = resolved.Max(r => r.Audience);
return new GroupCandidate(
group.Id,
group.Name,
units,
strictest,
seriesUnits >= singleUnits ? ShowKind.Series : ShowKind.Single,
seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single,
duration.TotalMinutes / units
)
);
}
return candidates;
}
/// <summary>Группа глазами раскладки: чем её можно закрыть и насколько её хватит.</summary>
private sealed record GroupCandidate(
Guid Id,
@@ -19,6 +19,20 @@ public static class GridProfiles
public static GridProfile Get(GridProfileKind kind) =>
All.FirstOrDefault(p => p.Kind == kind) ?? All[0];
// Названия полос, встречающиеся у нескольких профилей. Полосы — данные, и повторяющийся
// заголовок должен быть один: он попадает в название слота и виден админу в сетке.
private const string Morning = "Утро";
private const string Day = "День";
private const string DayBlock = "Дневной блок";
private const string DayMarathon = "Дневной марафон";
private const string Prime = "Прайм";
private const string Night = "Ночь";
private const string NightAir = "Ночной эфир";
private const string MorningMarathon = "Утренний марафон";
private const string GoodNight = "Спокойной ночи";
private const string SignOffTitle = "Конец вещания";
private const string PrimeMovie = "Кино в прайм";
private static TimeOnly At(int hour, int minute = 0) => new(hour, minute);
/// <summary>Ночной взрослый блок — общая концовка суток почти у всех профилей.</summary>
@@ -46,7 +60,7 @@ public static class GridProfiles
At(6),
At(10),
Daypart.Morning,
"Утро",
Morning,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 120,
@@ -56,7 +70,7 @@ public static class GridProfiles
At(10),
At(14),
Daypart.Day,
"День",
Day,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 120,
@@ -66,7 +80,7 @@ public static class GridProfiles
At(14),
At(18),
Daypart.Day,
"Дневной блок",
DayBlock,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 120,
@@ -86,7 +100,7 @@ public static class GridProfiles
At(20),
At(23),
Daypart.Prime,
"Прайм",
Prime,
ShowKind.Single,
BlockMinutes: 150,
UnitsPerBlock: 1,
@@ -94,7 +108,7 @@ public static class GridProfiles
CooldownDays: 14
),
AdultNight(At(23), At(2)),
new(At(2), At(6), Daypart.Night, "Ночной эфир", BlockMinutes: 240),
new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240),
],
[
new(
@@ -111,7 +125,7 @@ public static class GridProfiles
At(10),
At(14),
Daypart.Day,
"Дневной марафон",
DayMarathon,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 240
@@ -132,7 +146,7 @@ public static class GridProfiles
At(18),
At(23),
Daypart.Prime,
"Кино в прайм",
PrimeMovie,
ShowKind.Single,
BlockMinutes: 150,
UnitsPerBlock: 1,
@@ -140,7 +154,7 @@ public static class GridProfiles
CooldownDays: 21
),
AdultNight(At(23), At(2)),
new(At(2), At(6), Daypart.Night, "Ночной эфир", BlockMinutes: 240),
new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240),
]
);
@@ -166,7 +180,7 @@ public static class GridProfiles
At(10),
At(14),
Daypart.Day,
"Дневной блок",
DayBlock,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 120,
@@ -176,7 +190,7 @@ public static class GridProfiles
At(14),
At(19),
Daypart.Day,
"Дневной марафон",
DayMarathon,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 150,
@@ -186,21 +200,21 @@ public static class GridProfiles
At(19),
At(23),
Daypart.Prime,
"Прайм",
Prime,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 120,
UnitsPerBlock: 4
),
AdultNight(At(23), At(2)),
new(At(2), At(6), Daypart.Night, "Ночь", ShowKind.Series, BlockMinutes: 240),
new(At(2), At(6), Daypart.Night, Night, ShowKind.Series, BlockMinutes: 240),
],
[
new(
At(6),
At(12),
Daypart.Morning,
"Утренний марафон",
MorningMarathon,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 180,
@@ -210,7 +224,7 @@ public static class GridProfiles
At(12),
At(19),
Daypart.Day,
"Дневной марафон",
DayMarathon,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 210
@@ -219,14 +233,14 @@ public static class GridProfiles
At(19),
At(23),
Daypart.Prime,
"Прайм",
Prime,
ShowKind.Series,
ShowAudience.Pg13,
BlockMinutes: 120,
UnitsPerBlock: 4
),
AdultNight(At(23), At(3)),
new(At(3), At(6), Daypart.Night, "Ночь", ShowKind.Series, BlockMinutes: 180),
new(At(3), At(6), Daypart.Night, Night, ShowKind.Series, BlockMinutes: 180),
]
);
@@ -346,7 +360,7 @@ public static class GridProfiles
At(14),
At(18),
Daypart.Day,
"Дневной блок",
DayBlock,
BlockMinutes: 240,
Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 2
@@ -373,7 +387,7 @@ public static class GridProfiles
At(2),
At(6),
Daypart.Night,
"Ночь",
Night,
BlockMinutes: 240,
Strategy: SlotStrategyType.RandomWithCooldown,
CooldownDays: 1
@@ -418,7 +432,7 @@ public static class GridProfiles
At(18),
At(23),
Daypart.Prime,
"Кино в прайм",
PrimeMovie,
ShowKind.Single,
BlockMinutes: 150,
UnitsPerBlock: 1,
@@ -441,7 +455,7 @@ public static class GridProfiles
At(2),
At(6),
Daypart.Night,
"Ночной эфир",
NightAir,
ShowKind.Single,
BlockMinutes: 120,
UnitsPerBlock: 1,
@@ -464,7 +478,7 @@ public static class GridProfiles
At(6),
At(10),
Daypart.Morning,
"Утро",
Morning,
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 90,
@@ -474,7 +488,7 @@ public static class GridProfiles
At(10),
At(14),
Daypart.Day,
"День",
Day,
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 90,
@@ -484,7 +498,7 @@ public static class GridProfiles
At(14),
At(18),
Daypart.Day,
"Дневной блок",
DayBlock,
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 90,
@@ -504,7 +518,7 @@ public static class GridProfiles
At(21),
At(22),
Daypart.Prime,
"Спокойной ночи",
GoodNight,
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 60,
@@ -514,7 +528,7 @@ public static class GridProfiles
At(22),
At(6),
Daypart.Night,
"Конец вещания",
SignOffTitle,
SlotKind: SlotKind.SignOff,
BlockMinutes: 0
),
@@ -524,7 +538,7 @@ public static class GridProfiles
At(6),
At(10),
Daypart.Morning,
"Утренний марафон",
MorningMarathon,
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 240
@@ -533,7 +547,7 @@ public static class GridProfiles
At(10),
At(14),
Daypart.Day,
"День",
Day,
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 120,
@@ -543,7 +557,7 @@ public static class GridProfiles
At(14),
At(18),
Daypart.Day,
"Дневной блок",
DayBlock,
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 120,
@@ -565,7 +579,7 @@ public static class GridProfiles
At(21),
At(22),
Daypart.Prime,
"Спокойной ночи",
GoodNight,
ShowKind.Series,
ShowAudience.Pg,
BlockMinutes: 60,
@@ -575,7 +589,7 @@ public static class GridProfiles
At(22),
At(6),
Daypart.Night,
"Конец вещания",
SignOffTitle,
SlotKind: SlotKind.SignOff,
BlockMinutes: 0
),
@@ -38,9 +38,14 @@ public sealed class RestoreTemplateCommandHandler(IAppDbContext dbContext)
// Слои, которых в снимке нет, — заведены после применения. Фоновый не трогаем: он есть
// в любом шаблоне и удалению не подлежит.
foreach (var layer in template.Layers.ToList())
if (!layer.IsBackground && snapshot.Layers.All(l => l.Id != layer.Id))
template.RemoveLayer(layer.Id);
var staleLayers = template
.Layers.Where(layer =>
!layer.IsBackground && snapshot.Layers.All(l => l.Id != layer.Id)
)
.Select(layer => layer.Id)
.ToList();
foreach (var layerId in staleLayers)
template.RemoveLayer(layerId);
foreach (var layerSnapshot in snapshot.Layers)
RestoreLayer(template, layerSnapshot, refs);
@@ -76,9 +81,12 @@ public sealed class RestoreTemplateCommandHandler(IAppDbContext dbContext)
snapshot.IsEnabled
);
foreach (var slot in layer.Slots.ToList())
if (snapshot.Slots.All(s => s.Id != slot.Id))
layer.RemoveSlot(slot.Id);
var staleSlots = layer
.Slots.Where(slot => snapshot.Slots.All(s => s.Id != slot.Id))
.Select(slot => slot.Id)
.ToList();
foreach (var slotId in staleSlots)
layer.RemoveSlot(slotId);
foreach (var slotSnapshot in snapshot.Slots)
RestoreSlot(layer, slotSnapshot, refs);
@@ -212,9 +212,9 @@ public sealed class ValidateTemplateQueryHandler(
{
var slots = layers.SelectMany(l => l.Slots).ToList();
foreach (var weekday in GridCoverage.Week)
foreach (var (from, to) in GridCoverage.Gaps(slots, weekday, dayStart))
yield return Gap(weekday, from, to, dayStart);
return from weekday in GridCoverage.Week
from gap in GridCoverage.Gaps(slots, weekday, dayStart)
select Gap(weekday, gap.From, gap.To, dayStart);
}
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
@@ -152,10 +152,13 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
private static void DeleteCompanions(string directory, CompanionMatcher companion)
{
foreach (var file in Directory.EnumerateFiles(directory))
var companions = Directory
.EnumerateFiles(directory)
.Where(file => companion.Matches(Path.GetFileName(file)))
.ToList();
foreach (var file in companions)
{
if (!companion.Matches(Path.GetFileName(file)))
continue;
try
{
File.Delete(file);