diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs
index 5fe6fc4..cd5ce09 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlan.cs
@@ -52,7 +52,7 @@ public sealed record GridPlan(
GridProfile Profile,
GridGenerationMode Mode,
IReadOnlyList Slots,
- IReadOnlyList Notes,
+ IReadOnlyList Notes,
int SlotsToRemove,
Guid? FallbackGroupId,
string? FallbackGroupName,
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanDtos.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanDtos.cs
index 4de6c07..2a9c824 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanDtos.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanDtos.cs
@@ -32,7 +32,7 @@ public sealed record GridPlanDto(
GridProfileKind Profile,
string ProfileName,
IReadOnlyList Slots,
- IReadOnlyList Notes,
+ IReadOnlyList Notes,
int SlotsToRemove,
string? FallbackGroupName,
int FreeMinutes,
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs
index c9f5b7c..41d1694 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs
@@ -14,9 +14,12 @@ namespace TeleWave.Application.Programming.Templates.Generate;
/// потом создают слоты. Один и тот же расчёт для обеих операций — иначе показанное и созданное
/// разошлись бы.
///
-/// Подбор намеренно объяснимый: жёстко отсекается неподходящее по рейтингу, дальше баллы за тип
-/// контента, длину единицы и запас серий, минус за повторное использование в тех же сутках. Админ
-/// должен понимать, почему в прайм встала именно эта группа.
+/// Что делает раскладку осмысленной, а не просто заполненной:
+/// длина слота считается из длительности единицы группы (иначе трёхчасовой прайм с одним фильмом
+/// на 100 минут отдаёт 80 минут фону); ёмкость групп считается на неделю, а не на сутки (слот
+/// «каждый день» выходит семь раз); исчерпанный контент закрывается повтором вечернего блока, как
+/// на настоящем ТВ; жанр не занимает подряд две полосы. Подбор при этом остаётся объяснимым —
+/// жёсткий отсев по рейтингу, дальше баллы.
///
public sealed class GridPlanner(
IAppDbContext dbContext,
@@ -36,6 +39,9 @@ public sealed class GridPlanner(
/// Единица длиннее часа — это кино; короче — серия. Порог грубый и намеренно один.
private const int FeatureLengthMinutes = 60;
+ /// Дни, по которым идёт ротация в основном слое. Выходные живут своим слоем.
+ private static readonly int[] RotationDays = [1, 2, 3, 4, 5];
+
public async Task> BuildAsync(
Guid channelId,
GridProfileKind profileKind,
@@ -63,7 +69,12 @@ public sealed class GridPlanner(
return Result.Failure(TemplateErrors.NoGroupsToGenerate);
var profile = GridProfiles.Get(profileKind);
- var run = new PlanRun(profile, groups, channel.DayStartTime);
+ var run = new PlanRun(
+ profile,
+ groups,
+ channel.DayStartTime,
+ PlanningRules.FromJson(template.RulesJson)
+ );
// Занятым считается время слотов всех слоёв, включая выключенные: место, которое админ
// отвёл под слот и временно погасил, — не дыра, и застраивать его генератору нечего.
@@ -84,15 +95,27 @@ public sealed class GridPlanner(
var fallback = groups.MaxBy(g => g.UnitCount);
var needsFallback = template.FallbackGroupId is null && fallback is not null;
- var everyDaySpan = mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7;
+ var everyDaySpan = EveryDaySpan(mode, profile);
var covered = slots.Sum(s => s.DurationMinutes * (s.Weekday is null ? everyDaySpan : 1));
+ var notes = new List(run.Notes);
+ notes.AddRange(run.WeeklyChecks());
+ notes.AddRange(AudienceChecks(slots, run));
+ if (covered < freeMinutes)
+ notes.Add(
+ new PlanNote(
+ true,
+ $"После сборки останется незакрытым {Hours(freeMinutes - covered)} недели — "
+ + "это место в эфире закроет фон."
+ )
+ );
+
return Result.Success(
new GridPlan(
profile,
mode,
slots,
- run.Notes,
+ notes,
mode == GridGenerationMode.Rebuild ? existing.Count : 0,
needsFallback ? fallback!.Id : null,
needsFallback ? fallback!.Name : null,
@@ -102,26 +125,44 @@ public sealed class GridPlanner(
);
}
+ /// Сколько дней в неделю действует слот «каждый день»: выходные могут быть перекрыты.
+ private static int EveryDaySpan(GridGenerationMode mode, GridProfile profile) =>
+ mode == GridGenerationMode.Rebuild && profile.Weekend.Count > 0 ? 5 : 7;
+
+ private static string Hours(int minutes) =>
+ minutes >= 60 ? $"{minutes / 60} ч {minutes % 60:00} мин" : $"{minutes} мин";
+
///
/// Неделя с нуля: будни ложатся слотами «каждый день» в основной слой, выходные — отдельным
/// слоем поверх. Слой выходных перекрывает будний целиком, поэтому расхождение по субботе
/// и воскресенью получается без дублирования всей недели по дням.
+ ///
+ /// Полосы с ротацией — исключение: они раскладываются по дням недели поимённо, иначе в прайме
+ /// всю неделю стоял бы один и тот же фильм.
///
private static List BuildWeek(PlanRun run)
{
var wholeDay = new List<(int From, int To)> { (0, GridCoverage.MinutesInDay) };
- var slots = FillDay(
- run.Profile.Weekdays,
- wholeDay,
- new SlotTarget(null, GridPlanLayer.Main),
- run
- );
+ var plain = run.Profile.Weekdays.Where(b => !b.Rotate).ToList();
+ var rotating = run.Profile.Weekdays.Where(b => b.Rotate).ToList();
+
+ run.BeginDay();
+ var slots = FillDay(plain, wholeDay, new SlotTarget(null, GridPlanLayer.Main), run);
+ run.SnapshotDay();
+
+ foreach (var weekday in RotationDays)
+ {
+ run.BeginDay(fromBaseline: true);
+ var target = new SlotTarget(weekday, GridPlanLayer.Main);
+ slots.AddRange(FillDay(rotating, wholeDay, target, run));
+ }
if (run.Profile.Weekend.Count == 0)
return slots;
foreach (var weekday in new[] { 6, 0 })
{
+ run.BeginDay();
var target = new SlotTarget(weekday, GridPlanLayer.Weekend);
slots.AddRange(FillDay(run.Profile.Weekend, wholeDay, target, run));
}
@@ -140,34 +181,48 @@ public sealed class GridPlanner(
///
private static List FillExistingGaps(PlanRun run, IReadOnlyList existing)
{
- var entries = new List<(int Weekday, PlannedSlot Slot)>();
+ var perDay = GridCoverage.Week.ToDictionary(
+ day => day,
+ day => (IReadOnlyList<(int From, int To)>)GridCoverage.Gaps(existing, day, run.DayStart)
+ );
+
+ // Дыры, общие для всей недели, закрываются слотами «каждый день»: так сетка остаётся
+ // читаемой. Полосы с ротацией сюда не идут — им нужен свой контент на каждый день.
+ run.BeginDay();
+ var common = FillDay(
+ run.Profile.Weekdays.Where(b => !b.Rotate).ToList(),
+ GridCoverage.Intersect(perDay.Values),
+ new SlotTarget(null, GridPlanLayer.Main),
+ run
+ );
+ run.SnapshotDay();
+
+ var taken = Spans(common, run.DayStart);
+ var slots = new List(common);
foreach (var weekday in GridCoverage.Week)
{
- var gaps = GridCoverage.Gaps(existing, weekday, run.DayStart);
+ run.BeginDay(fromBaseline: true);
+ var rest = GridCoverage.Subtract(perDay[weekday], taken);
var target = new SlotTarget(weekday, GridPlanLayer.Main);
- foreach (var slot in FillDay(run.Profile.Weekdays, gaps, target, run))
- entries.Add((weekday, slot));
+ slots.AddRange(FillDay(run.Profile.Weekdays, rest, target, run));
}
- return Collapse(entries);
+ return slots;
}
- private static List Collapse(List<(int Weekday, PlannedSlot Slot)> entries)
- {
- var result = new List();
-
- foreach (var same in entries.GroupBy(e => e.Slot with { Weekday = null }))
- {
- var days = same.Select(e => e.Weekday).Distinct().Count();
- if (days == GridCoverage.Week.Length)
- result.Add(same.Key);
- else
- result.AddRange(same.Select(e => e.Slot));
- }
-
- return result;
- }
+ /// Занятые слотами интервалы суток — по ним считается, что дню ещё осталось закрыть.
+ private static List<(int From, int To)> Spans(
+ IEnumerable slots,
+ TimeOnly dayStart
+ ) =>
+ [
+ .. slots.Select(s =>
+ {
+ var from = GridCoverage.OffsetInDay(s.Start, dayStart);
+ return (From: from, To: from + s.DurationMinutes);
+ }),
+ ];
/// Раскладывает свободные интервалы одних суток по полосам профиля.
private static List FillDay(
@@ -177,9 +232,6 @@ public sealed class GridPlanner(
PlanRun run
)
{
- // Счётчик использований живёт внутри суток: группа, занятая утром, уступает вечером другой,
- // но на следующий день снова доступна — иначе раскладка зависела бы от порядка дней.
- var used = new Dictionary();
var slots = new List();
// Что реально предстоит застроить: полосы профиля, пересечённые со свободным временем.
@@ -190,16 +242,21 @@ public sealed class GridPlanner(
let start = Math.Max(piece.From, gap.From)
let end = Math.Min(piece.To, gap.To)
where end - start >= MinSlotMinutes
+ orderby start
select (Band: band, From: start, To: end);
foreach (var piece in pieces)
- slots.AddRange(FillPiece(piece.Band, piece.From, piece.To, target, run, used));
+ slots.AddRange(FillPiece(piece.Band, piece.From, piece.To, target, run));
return [.. slots.OrderBy(s => GridCoverage.OffsetInDay(s.Start, run.DayStart))];
}
/// Куда ложится слот: день недели и слой. Пара ходит вместе через всю раскладку.
- private readonly record struct SlotTarget(int? Weekday, GridPlanLayer Layer);
+ private readonly record struct SlotTarget(int? Weekday, GridPlanLayer Layer)
+ {
+ /// Сколько раз слот выходит за неделю: без дня недели — каждый день.
+ public int AiringsPerWeek => Weekday is null ? 7 : 1;
+ }
///
/// Полоса в смещениях от начала вещательных суток. Начало суток канала произвольно, поэтому
@@ -220,48 +277,32 @@ public sealed class GridPlanner(
yield return (0, to - GridCoverage.MinutesInDay);
}
- /// Режет кусок полосы на слоты целевой длины и подбирает каждому наполнение.
+ ///
+ /// Режет кусок полосы на слоты. Длину слота задаёт не профиль, а контент: блок из N единиц
+ /// занимает столько, сколько эти единицы идут. Профильный BlockMinutes остаётся целью
+ /// для полос, которые меряются временем, а не штуками.
+ ///
private static IEnumerable FillPiece(
GridBand band,
int from,
int to,
SlotTarget target,
- PlanRun run,
- Dictionary used
+ PlanRun run
)
{
- var length = to - from;
- var count =
- band.BlockMinutes <= 0
- ? 1
- : Math.Max(
- 1,
- (int)
- Math.Round(
- length / (double)band.BlockMinutes,
- MidpointRounding.AwayFromZero
- )
- );
-
- var step = Math.Max(MinSlotMinutes, length / count / SlotStepMinutes * SlotStepMinutes);
var cursor = from;
- while (cursor < to)
+ while (to - cursor >= MinSlotMinutes)
{
- var duration = Math.Min(step, to - cursor);
- // Хвост короче минимального слота приклеиваем к текущему: два слота по семь минут
- // читаются как ошибка сетки, а не как решение.
- if (to - cursor - duration < MinSlotMinutes)
- duration = to - cursor;
-
// Якорь — только на первом слоте прайма: жёсткий старт у каждого слота подряд заставил
// бы генератор крутить фон перед каждым из них.
var anchor = cursor == from && band.Daypart == Daypart.Prime;
- var slot = Compose(band, target, cursor, duration, anchor, run, used);
- if (slot is not null)
- yield return slot;
+ var slot = Compose(band, target, cursor, to - cursor, anchor, run);
+ if (slot is null)
+ yield break;
- cursor += duration;
+ yield return slot;
+ cursor += slot.DurationMinutes;
}
}
@@ -269,53 +310,81 @@ public sealed class GridPlanner(
GridBand band,
SlotTarget target,
int offset,
- int duration,
+ int available,
bool anchor,
- PlanRun run,
- Dictionary used
+ PlanRun run
)
{
- var start = GridCoverage.AtOffset(offset, run.DayStart);
-
if (band.SlotKind == SlotKind.SignOff)
- return New(band.Title, null, SlotBlockMode.FillSlot, 1, null, null);
+ return Build(band.Title, null, available, SlotBlockMode.FillSlot, 1, null, null);
if (band.SlotKind == SlotKind.Repeat)
- return New(
+ return RepeatSlot(band.Title, band.RepeatFrom, available);
+
+ var group = Pick(band, target, run, available);
+
+ // Ставить нечего — либо подходящей группы нет вовсе, либо свежий контент на неделю кончился.
+ // И то и другое закрывается повтором вечернего блока, как на настоящем ТВ: крутить по
+ // третьему кругу одно и то же хуже — зритель видит то же самое, но без подписи «повтор».
+ if (group is null || run.Exhausted(group))
+ {
+ if (!CanRepeat(band, run))
+ {
+ run.Note(Unfillable(band, group), isError: group is null);
+ return group is null ? null : ContentSlot(group);
+ }
+
+ run.Note(
+ group is null
+ ? $"Для полосы «{band.Title}» подходящей группы нет — её закрывает повтор "
+ + "вечернего блока."
+ : $"Полосу «{band.Title}» закрывает повтор вечернего блока: свежего контента "
+ + "на неделю не хватает."
+ );
+ return RepeatSlot(
band.Title,
+ run.PrimeStart!.Value,
+ Math.Min(available, RepeatMinutes(band))
+ );
+ }
+
+ return ContentSlot(group);
+
+ PlannedSlot ContentSlot(GroupCandidate picked)
+ {
+ var (duration, units) = Measure(band, picked, available);
+ run.Reserve(picked, units, duration, target.AiringsPerWeek);
+
+ return Build(
+ picked.Name,
+ picked.Id,
+ duration,
+ band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot,
+ Math.Max(1, units),
+ new SlotStrategy(
+ band.Strategy,
+ RestartOnEnd: true,
+ CooldownDays: band.CooldownDays
+ ),
+ null
+ );
+ }
+
+ PlannedSlot RepeatSlot(string title, TimeOnly repeatFrom, int minutes) =>
+ Build(
+ title,
null,
+ minutes,
SlotBlockMode.FillSlot,
1,
null,
- new RepeatSource(band.RepeatDaysAgo, band.RepeatFrom, duration)
+ new RepeatSource(band.RepeatDaysAgo, repeatFrom, minutes)
);
- var group = Pick(band, run, used);
- if (group is null)
- {
- run.Note(
- $"Полоса «{band.Title}» ({band.From:HH\\:mm}–{band.To:HH\\:mm}): подходящей группы "
- + "нет — интервал останется незакрытым."
- );
- return null;
- }
-
- used[group.Id] = used.GetValueOrDefault(group.Id) + 1;
- run.WarnIfThin(band, group, target.Weekday);
-
- var blockMode = band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot;
- return New(
- group.Name,
- group.Id,
- blockMode,
- Math.Max(1, band.UnitsPerBlock),
- new SlotStrategy(band.Strategy, RestartOnEnd: true, CooldownDays: band.CooldownDays),
- null
- );
-
- PlannedSlot New(
+ PlannedSlot Build(
string title,
Guid? groupId,
+ int duration,
SlotBlockMode blockMode,
int blockValue,
SlotStrategy? strategy,
@@ -324,11 +393,11 @@ public sealed class GridPlanner(
new(
target.Layer,
target.Weekday,
- start,
+ GridCoverage.AtOffset(offset, run.DayStart),
duration,
title,
band.Daypart,
- band.SlotKind,
+ repeat is null ? band.SlotKind : SlotKind.Repeat,
groupId,
groupId is null ? null : title,
blockMode,
@@ -339,16 +408,91 @@ public sealed class GridPlanner(
);
}
+ /// Почему полоса осталась без свежего контента — текст для предпросмотра.
+ private static string Unfillable(GridBand band, GroupCandidate? group) =>
+ group is null
+ ? $"Полоса «{band.Title}» ({band.From:HH\\:mm}–{band.To:HH\\:mm}): подходящей группы "
+ + "нет — интервал останется незакрытым."
+ : $"В полосе «{band.Title}» контент пойдёт по второму кругу: свежего на неделю не хватает.";
+
+ /// Повторять вечерний блок можно везде, кроме самого прайма, и только если он есть.
+ private static bool CanRepeat(GridBand band, PlanRun run) =>
+ band.Daypart != Daypart.Prime && run.PrimeStart is not null;
+
+ private static int RepeatMinutes(GridBand band) =>
+ band.BlockMinutes > 0 ? band.BlockMinutes : 120;
+
+ ///
+ /// Длина слота и число единиц в нём. Блок из N единиц занимает ровно столько, сколько эти
+ /// единицы идут, — округление до пяти минут; хвост короче минимального слота приклеивается
+ /// к текущему, чтобы в сетке не появлялось огрызков.
+ ///
+ private static (int Duration, int Units) Measure(
+ GridBand band,
+ GroupCandidate group,
+ int available
+ )
+ {
+ var unit = UnitMinutes(group);
+
+ // Полоса, которая меряется временем (марафон, ночь), берёт свой кусок целиком.
+ if (band.UnitsPerBlock <= 0 || unit == 0)
+ 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)));
+ }
+
+ ///
+ /// Длина слота: желаемая, но не больше свободного места. Остаток короче минимального слота
+ /// приклеивается к текущему — огрызок в сетке читается как ошибка, а не как решение. Остаток
+ /// побольше достанется следующему слоту, и под него подберут группу подходящей длины.
+ ///
+ private static int Fit(int wanted, int available)
+ {
+ var duration = Math.Min(Round(wanted), available);
+ return available - duration < MinSlotMinutes
+ ? available
+ : Math.Max(duration, MinSlotMinutes);
+ }
+
+ private static int Round(int minutes) =>
+ Math.Max(SlotStepMinutes, minutes / SlotStepMinutes * SlotStepMinutes);
+
+ /// Средняя длина единицы группы, округлённая до пяти минут (0 — длительностей нет).
+ private static int UnitMinutes(GroupCandidate group) =>
+ group.AverageUnitMinutes <= 0 ? 0 : Round((int)Math.Round(group.AverageUnitMinutes));
+
///
/// Группа под полосу. Рейтинг — жёсткий отсев: в детское время строгое не ставится, а ночной
/// взрослый блок без взрослого содержимого не имеет смысла. Остальное — баллы.
+ ///
+ /// У полосы с ротацией берётся не лучший кандидат, а следующий по кругу: в прайме понедельника
+ /// и вторника должно стоять разное, иначе неделя выглядит одним повторяющимся днём.
///
- private static GroupCandidate? Pick(GridBand band, PlanRun run, Dictionary used) =>
- run
+ private static GroupCandidate? Pick(
+ GridBand band,
+ SlotTarget target,
+ PlanRun run,
+ int available
+ )
+ {
+ var ranked = run
.Groups.Where(g => Allowed(band, g))
- .OrderByDescending(g => Score(band, g, used))
+ .OrderByDescending(g => Score(band, g, run, available))
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)
- .FirstOrDefault();
+ .ToList();
+
+ 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];
+ }
private static bool Allowed(GridBand band, GroupCandidate group)
{
@@ -361,10 +505,15 @@ public sealed class GridPlanner(
|| (group.Strictest is { } audience && audience >= min);
}
- private static int Score(GridBand band, GroupCandidate group, Dictionary used)
+ private static int Score(GridBand band, GroupCandidate group, PlanRun run, int available)
{
var score = Math.Min(group.UnitCount, 60);
+ // Единица длиннее оставшегося места — до ближайшего якоря она не влезет, и место закроет
+ // фон. Такой кандидат хуже любого, который поместится.
+ if (UnitMinutes(group) > available)
+ score -= 80;
+
if (band.PreferKind is { } kind)
score += group.DominantKind == kind ? 100 : -40;
@@ -373,8 +522,52 @@ public sealed class GridPlanner(
if (band.PreferKind is not null && wantsFeature == isFeature)
score += 30;
+ // Ёмкость на неделю: группа, уже разобранная под другие слоты, уступает нетронутой.
+ if (run.Exhausted(group))
+ score -= 70;
+
+ // Два блока одного жанра подряд читаются как один длинный — канал теряет ритм.
+ if (run.FollowsSameGenre(group))
+ score -= 25;
+
+ if (run.Rules?.MaxGenreSharePercent is { } maxShare && run.GenreShare(group) >= maxShare)
+ score -= 60;
+
// Одна и та же группа во всех полосах суток — формально валидная, но бессмысленная сетка.
- return score - used.GetValueOrDefault(group.Id) * 50;
+ return score - run.UsedToday(group.Id) * 50;
+ }
+
+ ///
+ /// Конфликты с детским временем: слот стоит в окне, где потолок строже, чем контент группы.
+ /// Проверка та же, что делают проверки сетки, но по плану — до того, как слоты созданы.
+ ///
+ private static IEnumerable AudienceChecks(
+ IReadOnlyList slots,
+ PlanRun run
+ )
+ {
+ if (run.Rules is not { } rules)
+ yield break;
+
+ var seen = new HashSet<(Guid, ShowAudience)>();
+
+ foreach (var slot in slots)
+ {
+ if (slot.GroupId is not { } groupId)
+ continue;
+ if (run.Groups.FirstOrDefault(g => g.Id == groupId) is not { Strictest: { } audience })
+ continue;
+ if (rules.AudienceAt(slot.Start) is not { } max || audience <= max)
+ continue;
+ if (!seen.Add((groupId, max)))
+ continue;
+
+ yield return new PlanNote(
+ false,
+ $"В группе «{slot.Title}» есть контент категории «{audience}», а слот "
+ + $"{slot.Start:HH\\:mm} стоит во времени не строже «{max}»."
+ );
+ }
}
private async Task> LoadGroupsAsync(CancellationToken cancellationToken)
@@ -398,14 +591,57 @@ public sealed class GridPlanner(
composition.Select(e => (e.Kind, e.Id)),
cancellationToken
);
+ var genreId = await PrimaryGenreAsync(composition, cancellationToken);
- if (Describe(group, composition, info) is { } candidate)
+ if (Describe(group, composition, info, genreId) is { } candidate)
candidates.Add(candidate);
}
return candidates;
}
+ ///
+ /// Основной жанр группы — самый частый среди её шоу. Нужен, чтобы не ставить подряд две полосы
+ /// одного жанра и не выйти за потолок доли жанра из правил канала.
+ ///
+ private async Task PrimaryGenreAsync(
+ IReadOnlyList 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;
+ }
+
///
/// Сводит вычисленный состав к тому, что нужно раскладке: сколько эфира группа даёт, какой она
/// в основном природы и насколько строгий в ней контент. Группа без готовых единиц не годится
@@ -414,7 +650,8 @@ public sealed class GridPlanner(
private static GroupCandidate? Describe(
Group group,
IReadOnlyList composition,
- IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo> info
+ IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo> info,
+ Guid? primaryGenreId
)
{
var resolved = composition
@@ -429,63 +666,16 @@ public sealed class GridPlanner(
// У коллекции своего типа нет: франшиза — это почти всегда полнометражки.
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,
+ composition.Count,
units,
- strictest,
+ resolved.Max(r => r.Audience),
seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single,
- duration.TotalMinutes / units
+ duration.TotalMinutes / units,
+ primaryGenreId
);
}
-
- /// Группа глазами раскладки: чем её можно закрыть и насколько её хватит.
- private sealed record GroupCandidate(
- Guid Id,
- string Name,
- int UnitCount,
- ShowAudience? Strictest,
- ShowKind DominantKind,
- double AverageUnitMinutes
- );
-
- /// Общее состояние одного разбора: профиль, кандидаты и накопленные замечания.
- private sealed class PlanRun(
- GridProfile profile,
- IReadOnlyList groups,
- TimeOnly dayStart
- )
- {
- private readonly List _notes = [];
- private readonly HashSet _seen = [];
-
- public GridProfile Profile { get; } = profile;
- public IReadOnlyList Groups { get; } = groups;
- public TimeOnly DayStart { get; } = dayStart;
- public IReadOnlyList Notes => _notes;
-
- public void Note(string text)
- {
- if (_seen.Add(text))
- _notes.Add(text);
- }
-
- ///
- /// Предупреждение о нехватке состава — то же, о чём потом скажут проверки сетки. Показать
- /// это до создания слотов честнее, чем дать админу нажать кнопку и получить список ошибок.
- ///
- public void WarnIfThin(GridBand band, GroupCandidate group, int? weekday)
- {
- var perWeek = (weekday is null ? 7 : 1) * Math.Max(1, band.UnitsPerBlock);
- if (group.UnitCount >= perWeek)
- return;
-
- Note(
- $"В группе «{group.Name}» {group.UnitCount} единиц при {perWeek} выходах в неделю "
- + $"в полосе «{band.Title}» — повторы пойдут чаще, чем раз в неделю."
- );
- }
- }
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs
index 9647f0f..3af7ef3 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfile.cs
@@ -38,7 +38,9 @@ public sealed record GridBand(
int CooldownDays = 0,
SlotKind SlotKind = SlotKind.Content,
int RepeatDaysAgo = 1,
- TimeOnly RepeatFrom = default
+ TimeOnly RepeatFrom = default,
+ /// Раскладывать по дням недели с чередованием групп — для прайма, где нужен разный контент.
+ bool Rotate = false
)
{
/// Длина полосы в минутах; полоса через полночь считается вперёд, а не назад.
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs
index 7bea3e6..3358140 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridProfiles.cs
@@ -105,7 +105,8 @@ public static class GridProfiles
BlockMinutes: 150,
UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown,
- CooldownDays: 14
+ CooldownDays: 14,
+ Rotate: true
),
AdultNight(At(23), At(2)),
new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240),
@@ -151,7 +152,8 @@ public static class GridProfiles
BlockMinutes: 150,
UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown,
- CooldownDays: 21
+ CooldownDays: 21,
+ Rotate: true
),
AdultNight(At(23), At(2)),
new(At(2), At(6), Daypart.Night, NightAir, BlockMinutes: 240),
@@ -291,7 +293,8 @@ public static class GridProfiles
BlockMinutes: 120,
UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown,
- CooldownDays: 14
+ CooldownDays: 14,
+ Rotate: true
),
AdultNight(At(0), At(6)),
],
@@ -437,7 +440,8 @@ public static class GridProfiles
BlockMinutes: 150,
UnitsPerBlock: 1,
Strategy: SlotStrategyType.RandomWithCooldown,
- CooldownDays: 30
+ CooldownDays: 30,
+ Rotate: true
),
new(
At(23),
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/PlanRun.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/PlanRun.cs
new file mode 100644
index 0000000..550aafa
--- /dev/null
+++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/PlanRun.cs
@@ -0,0 +1,147 @@
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Programming;
+
+namespace TeleWave.Application.Programming.Templates.Generate;
+
+/// Группа глазами раскладки: чем её можно закрыть, надолго ли хватит и какого она жанра.
+/// Позиций в составе — по ним считается «повторы чаще раза в неделю».
+/// Единиц воспроизведения: столько эфира группа даёт без повторов.
+public sealed record GroupCandidate(
+ Guid Id,
+ string Name,
+ int ElementCount,
+ int UnitCount,
+ ShowAudience? Strictest,
+ ShowKind DominantKind,
+ double AverageUnitMinutes,
+ Guid? PrimaryGenreId
+);
+
+/// Замечание к плану: IsError — то, что после сборки останется дырой в эфире.
+public sealed record PlanNote(bool IsError, string Text);
+
+///
+/// Состояние одного разбора: кандидаты, бюджет недели, дневные счётчики и накопленные замечания.
+///
+/// Бюджет недельный намеренно: слот «каждый день» выходит семь раз, и группа из двенадцати серий
+/// закрывает им максимум полторы недели. Считать это внутри суток бессмысленно — там всё всегда
+/// сходится, а на неделе не сходится ничего.
+///
+public sealed class PlanRun(
+ GridProfile profile,
+ IReadOnlyList groups,
+ TimeOnly dayStart,
+ PlanningRules? rules
+)
+{
+ private readonly Dictionary _weekUnits = [];
+ private readonly Dictionary _weekAirings = [];
+ private Dictionary _dayUsed = [];
+ private Dictionary _dayGenreMinutes = [];
+ private Dictionary _baselineUsed = [];
+ private Dictionary _baselineGenre = [];
+ private int _dayMinutes;
+ private int _baselineMinutes;
+ private Guid? _lastGenreId;
+
+ private readonly List _notes = [];
+ private readonly HashSet _seen = [];
+
+ public GridProfile Profile { get; } = profile;
+ public IReadOnlyList Groups { get; } = groups;
+ public TimeOnly DayStart { get; } = dayStart;
+ public PlanningRules? Rules { get; } = rules;
+
+ /// Начало прайма профиля — точка, на которую ссылаются утренние повторы.
+ public TimeOnly? PrimeStart =>
+ Profile.Weekdays.FirstOrDefault(b => b.Daypart == Daypart.Prime)?.From;
+
+ public IReadOnlyList Notes => _notes;
+
+ public void Note(string text, bool isError = false)
+ {
+ if (_seen.Add(text))
+ _notes.Add(new PlanNote(isError, text));
+ }
+
+ // ── Сутки ────────────────────────────────────────────────────────────
+
+ /// Начать сутки. С — продолжить с отложенного среза.
+ public void BeginDay(bool fromBaseline = false)
+ {
+ _dayUsed = fromBaseline ? new Dictionary(_baselineUsed) : [];
+ _dayGenreMinutes = fromBaseline ? new Dictionary(_baselineGenre) : [];
+ _dayMinutes = fromBaseline ? _baselineMinutes : 0;
+ _lastGenreId = null;
+ }
+
+ ///
+ /// Запомнить состояние суток. Нужно, когда день строится в два прохода: сначала общие полосы
+ /// «каждый день», потом ротация по дням недели — второй проход обязан видеть первый, иначе
+ /// вечером встанет то же, что стояло утром.
+ ///
+ public void SnapshotDay()
+ {
+ _baselineUsed = new Dictionary(_dayUsed);
+ _baselineGenre = new Dictionary(_dayGenreMinutes);
+ _baselineMinutes = _dayMinutes;
+ }
+
+ public int UsedToday(Guid groupId) => _dayUsed.GetValueOrDefault(groupId);
+
+ /// Доля суток, уже отданная жанру группы, в процентах.
+ public int GenreShare(GroupCandidate group)
+ {
+ if (group.PrimaryGenreId is not { } genreId || _dayMinutes == 0)
+ return 0;
+ return 100 * _dayGenreMinutes.GetValueOrDefault(genreId) / _dayMinutes;
+ }
+
+ public bool FollowsSameGenre(GroupCandidate group) =>
+ group.PrimaryGenreId is { } genreId && _lastGenreId == genreId;
+
+ // ── Неделя ───────────────────────────────────────────────────────────
+
+ /// Единиц группы, ещё не разобранных под слоты этой недели (может уйти в минус).
+ public int RemainingUnits(GroupCandidate group) =>
+ group.UnitCount - _weekUnits.GetValueOrDefault(group.Id);
+
+ /// Ёмкость исчерпана: дальше группа пойдёт по второму кругу.
+ public bool Exhausted(GroupCandidate group) => RemainingUnits(group) <= 0;
+
+ /// Учесть поставленный слот: единицы недели, выходы, минуты суток и жанр.
+ public void Reserve(GroupCandidate group, int units, int minutes, int airingsPerWeek)
+ {
+ _weekUnits[group.Id] = _weekUnits.GetValueOrDefault(group.Id) + units * airingsPerWeek;
+ _weekAirings[group.Id] = _weekAirings.GetValueOrDefault(group.Id) + airingsPerWeek;
+ _dayUsed[group.Id] = _dayUsed.GetValueOrDefault(group.Id) + 1;
+ _dayMinutes += minutes;
+
+ if (group.PrimaryGenreId is not { } genreId)
+ return;
+
+ _dayGenreMinutes[genreId] = _dayGenreMinutes.GetValueOrDefault(genreId) + minutes;
+ _lastGenreId = genreId;
+ }
+
+ ///
+ /// Что останется не так после сборки. Считается по тому же бюджету, по которому раскладывалось,
+ /// поэтому цифры точные: «84 выхода при 40 позициях» — это ровно то, что потом скажут проверки
+ /// сетки, но сказанное до нажатия кнопки.
+ ///
+ public IEnumerable WeeklyChecks()
+ {
+ foreach (var (groupId, airings) in _weekAirings.OrderBy(p => p.Value))
+ {
+ var group = Groups.FirstOrDefault(g => g.Id == groupId);
+ if (group is null || group.ElementCount >= airings)
+ continue;
+
+ yield return new PlanNote(
+ false,
+ $"В группе «{group.Name}» {group.ElementCount} позиций при {airings} выходах "
+ + "в неделю — повторы пойдут чаще, чем раз в неделю."
+ );
+ }
+ }
+}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/GridCoverage.cs b/backend/src/TeleWave.Application/Programming/Templates/GridCoverage.cs
index c60e2fe..2fe2765 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/GridCoverage.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/GridCoverage.cs
@@ -69,4 +69,72 @@ public static class GridCoverage
return gaps;
}
+
+ /// Интервалы, свободные во всех наборах сразу.
+ public static List<(int From, int To)> Intersect(
+ IEnumerable> sets
+ )
+ {
+ var mask = new bool[MinutesInDay];
+ Array.Fill(mask, true);
+
+ foreach (var set in sets)
+ {
+ var current = ToMask(set);
+ for (var minute = 0; minute < MinutesInDay; minute++)
+ mask[minute] &= current[minute];
+ }
+
+ return FromMask(mask);
+ }
+
+ /// Что останется от интервалов, если вычесть из них другие.
+ public static List<(int From, int To)> Subtract(
+ IReadOnlyList<(int From, int To)> from,
+ IReadOnlyList<(int From, int To)> minus
+ )
+ {
+ var mask = ToMask(from);
+ var cut = ToMask(minus);
+
+ for (var minute = 0; minute < MinutesInDay; minute++)
+ mask[minute] &= !cut[minute];
+
+ return FromMask(mask);
+ }
+
+ ///
+ /// Интервалы считаются поминутной маской: суток всего 1440 минут, а очевидная правильность
+ /// здесь дороже экономии — на этих операциях держится то, какие куски сетки застраиваются.
+ ///
+ private static bool[] ToMask(IReadOnlyList<(int From, int To)> intervals)
+ {
+ var mask = new bool[MinutesInDay];
+
+ foreach (var (from, to) in intervals)
+ for (var minute = Math.Max(0, from); minute < Math.Min(to, MinutesInDay); minute++)
+ mask[minute] = true;
+
+ return mask;
+ }
+
+ private static List<(int From, int To)> FromMask(bool[] mask)
+ {
+ var result = new List<(int From, int To)>();
+ var start = -1;
+
+ for (var minute = 0; minute <= MinutesInDay; minute++)
+ {
+ var covered = minute < MinutesInDay && mask[minute];
+ if (covered && start < 0)
+ start = minute;
+ else if (!covered && start >= 0)
+ {
+ result.Add((start, minute));
+ start = -1;
+ }
+ }
+
+ return result;
+ }
}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs
index ab52ebc..0fd6ada 100644
--- a/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs
+++ b/backend/tests/TeleWave.Application.Tests/Programming/GenerateGridTests.cs
@@ -147,8 +147,20 @@ public class GenerateGridTests
// Пустая сетка свободна всю неделю, и профиль обязан закрыть её целиком.
Assert.Equal(7 * GridCoverage.MinutesInDay, plan.FreeMinutes);
Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
- // Свободно во все семь дней — значит слоты идут «каждый день», а не семью копиями.
- Assert.All(plan.Slots, slot => Assert.Null(slot.Weekday));
+ // Полосы без ротации свободны во все семь дней — они идут «каждый день», а не семью копиями.
+ Assert.All(
+ plan.Slots.Where(s => s.Start < new TimeOnly(20, 0)),
+ slot => Assert.Null(slot.Weekday)
+ );
+ // Прайм (с 20:00) чередуется по дням недели, поэтому у него дни проставлены явно.
+ Assert.All(
+ plan.Slots.Where(s => s.Start == new TimeOnly(20, 0)),
+ slot => Assert.NotNull(slot.Weekday)
+ );
+ Assert.Equal(
+ 5,
+ plan.Slots.Count(s => s.Start == new TimeOnly(20, 0) && s.Weekday is >= 1 and <= 5)
+ );
}
[Fact]
@@ -215,8 +227,13 @@ public class GenerateGridTests
var plan = await PlanAsync(fixture, channelId);
- Assert.Contains(plan.Notes, note => note.Contains("Ночной блок 18+"));
- Assert.True(plan.CoveredMinutes < plan.FreeMinutes);
+ // Взрослого контента нет — ночной блок закрывается повтором вечернего, а не остаётся дырой.
+ Assert.Contains(plan.Notes, note => note.Text.Contains("Ночной блок 18+"));
+ Assert.Contains(
+ plan.Slots,
+ s => s.SlotKind == SlotKind.Repeat && s.Title == "Ночной блок 18+"
+ );
+ Assert.Equal(plan.FreeMinutes, plan.CoveredMinutes);
}
[Fact]
@@ -304,6 +321,109 @@ public class GenerateGridTests
Assert.Contains(plan.Slots, s => s.Layer == GridPlanLayer.Main && s.Weekday is null);
}
+ [Fact]
+ public async Task SlotLength_FollowsContent_NotProfileConstant()
+ {
+ // Серии по 25 минут: двухчасовая полоса должна разложиться на блоки, кратные серии,
+ // а не отдать десятки минут фону.
+ var fixture = new TestDb();
+ var series = Playable("Сериал", ShowKind.Series, 60, 25);
+ var (channelId, _) = await SeedChannelAsync(
+ fixture,
+ [series.Show],
+ series.Assets,
+ [GroupOf("Сериалы", series.Show.Id)]
+ );
+
+ var plan = await PlanAsync(fixture, channelId);
+
+ var morning = plan.Slots.Where(s => s.Daypart == Daypart.Morning && s.GroupId is not null);
+ Assert.All(
+ morning,
+ slot =>
+ {
+ // Слот вмещает ровно столько серий, сколько в нём заявлено, и с точностью до пяти минут.
+ Assert.Equal(slot.DurationMinutes, slot.BlockValue * 25);
+ Assert.Equal(SlotBlockMode.Count, slot.BlockMode);
+ }
+ );
+ }
+
+ [Fact]
+ public async Task ExhaustedGroup_IsReplacedByRepeatOfPrime()
+ {
+ // Контента на неделю заведомо мало: сетка должна закрыть день повторами, а не крутить
+ // одно и то же по третьему кругу молча.
+ var fixture = new TestDb();
+ var tiny = Playable("Мало", ShowKind.Series, 3, 25);
+ var (channelId, _) = await SeedChannelAsync(
+ fixture,
+ [tiny.Show],
+ tiny.Assets,
+ [GroupOf("Мало", tiny.Show.Id)]
+ );
+
+ var plan = await PlanAsync(fixture, channelId);
+
+ Assert.Contains(plan.Slots, s => s.SlotKind == SlotKind.Repeat);
+ Assert.Contains(plan.Notes, n => n.Text.Contains("повтор вечернего блока"));
+ // Повтор ссылается на вечерний блок прошлых суток — с начала прайма профиля.
+ var repeat = plan.Slots.First(s => s.RepeatSource is not null);
+ Assert.Equal(1, repeat.RepeatSource!.DaysAgo);
+ Assert.Equal(new TimeOnly(18, 0), repeat.RepeatSource.Time);
+ }
+
+ [Fact]
+ public async Task PrimeRotatesAcrossWeekdays()
+ {
+ var fixture = new TestDb();
+ var first = Films(6, 100);
+ var second = Playable("Сериал", ShowKind.Series, 40, 25);
+ var (channelId, _) = await SeedChannelAsync(
+ fixture,
+ [second.Show, .. first.Shows],
+ [.. second.Assets, .. first.Assets],
+ [
+ GroupOf("Кино А", first.Shows[0].Id, first.Shows[1].Id, first.Shows[2].Id),
+ GroupOf("Кино Б", first.Shows[3].Id, first.Shows[4].Id, first.Shows[5].Id),
+ GroupOf("Сериалы", second.Show.Id),
+ ]
+ );
+
+ var plan = await PlanAsync(
+ fixture,
+ channelId,
+ GridProfileKind.Mixed,
+ GridGenerationMode.Rebuild
+ );
+
+ var prime = plan
+ .Slots.Where(s => s.Start == new TimeOnly(20, 0) && s.Weekday is >= 1 and <= 5)
+ .ToList();
+
+ Assert.Equal(5, prime.Count);
+ // Всю неделю один и тот же фильм в прайме — это не сетка, а один повторяющийся день.
+ Assert.True(prime.Select(s => s.GroupName).Distinct().Count() > 1);
+ }
+
+ [Fact]
+ public async Task WeeklyShortage_IsReportedWithNumbers()
+ {
+ var fixture = new TestDb();
+ var series = Playable("Сериал", ShowKind.Series, 4, 25);
+ var (channelId, _) = await SeedChannelAsync(
+ fixture,
+ [series.Show],
+ series.Assets,
+ [GroupOf("Сериалы", series.Show.Id)]
+ );
+
+ var plan = await PlanAsync(fixture, channelId);
+
+ // Проверки считаются по тому же бюджету, по которому раскладывалось, — до нажатия кнопки.
+ Assert.Contains(plan.Notes, n => n.Text.Contains("выходах в неделю"));
+ }
+
[Fact]
public async Task Fails_WhenNothingToPlanFrom()
{
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs
index 58588c3..c459e52 100644
--- a/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs
+++ b/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs
@@ -78,7 +78,7 @@ public class GridPlanPreviewTests
Slot("Повтор", SlotKind.Repeat, SlotBlockMode.FillSlot, 1),
Slot("Конец вещания", SlotKind.SignOff, SlotBlockMode.FillSlot, 1),
],
- ["замечание"],
+ [new PlanNote(false, "замечание")],
SlotsToRemove: 4,
FallbackGroupId: Guid.NewGuid(),
FallbackGroupName: "Фон",
@@ -92,7 +92,7 @@ public class GridPlanPreviewTests
Assert.Equal(plan.Profile.Name, dto.ProfileName);
Assert.Equal(4, dto.SlotsToRemove);
Assert.Equal("Фон", dto.FallbackGroupName);
- Assert.Equal(["замечание"], dto.Notes);
+ Assert.Equal(["замечание"], dto.Notes.Select(n => n.Text));
// Описание блока — то, что читает админ вместо режима и числа.
Assert.Equal(
diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md
index 962ddaa..d89b631 100644
--- a/docs/tv-scheduler-architecture.md
+++ b/docs/tv-scheduler-architecture.md
@@ -860,9 +860,34 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
будни слоем `Основная сетка`, а выходные — слоем `Выходные` поверх: слой имеет смысл, только когда
покрывает день целиком, поэтому отдельные выходные бывают лишь в этом режиме.
+**Длину слота задаёт контент, а не профиль.** `BlockMinutes`/`UnitsPerBlock` — замысел полосы;
+фактическая длина считается из средней длительности единицы выбранной группы. Иначе двухчасовой
+слот с «тремя сериями по 22 минуты» отдаёт 54 минуты фону, а трёхчасовой прайм с одним фильмом на
+100 минут — восемьдесят. Остаток, не влезающий под ещё одну единицу, достаётся следующему слоту, и
+под него подбирается группа подходящей длины: кандидат, чья единица длиннее оставшегося места,
+штрафуется — до ближайшего якоря он всё равно не влезет.
+
+**Ёмкость считается на неделю.** Слот «каждый день» выходит семь раз, поэтому бюджет группы
+(`PlanRun`) списывается сразу на все выходы. Исчерпанная группа уступает нетронутой, а когда
+свежего контента не остаётся вовсе — полоса закрывается **повтором вечернего блока**
+(`SlotKind.Repeat` на начало прайма прошлых суток), как это делают настоящие каналы. Тем же
+повтором закрывается полоса, под которую подходящей группы нет вовсе (например ночной блок 18+
+на канале без взрослого контента) — это честнее дыры в эфире.
+
+**Прайм чередуется по дням недели.** Полосы с `Rotate` раскладываются поимённо по будням и берут
+не лучшего кандидата, а следующего по кругу: иначе неделя выглядит одним повторяющимся днём.
+Остальные полосы остаются слотами «каждый день» — сетка должна читаться.
+
+**Жанр не занимает две полосы подряд**, а при заданном `MaxGenreSharePercent` — и больше своей
+доли суток. Это тот же потолок, который потом проверяет пост-проверка из 3.8, но соблюдаемый
+на раскладке, а не констатируемый после.
+
**Предпросмотр обязателен**: он считается тем же `GridPlanner`, что и создание, поэтому показанное
-и созданное совпадают по построению. Эфир генерация не двигает — как любая правка сетки, она только
-поднимает ревизию шаблона, а хвост пересобирает кнопка применения.
+и созданное совпадают по построению. Там же показываются замечания — незакрытое время (красным),
+нехватка состава с точными числами («84 выхода в неделю при 40 позициях») и конфликты с детским
+временем: всё это считается по тому же бюджету, по которому раскладывалось, и потому совпадает
+с тем, что потом скажут проверки сетки. Эфир генерация не двигает — как любая правка сетки, она
+только поднимает ревизию шаблона, а хвост пересобирает кнопка применения.
---
diff --git a/frontend/src/features/admin/channels/components/GenerateGridDialog.tsx b/frontend/src/features/admin/channels/components/GenerateGridDialog.tsx
index 811922a..fb8d438 100644
--- a/frontend/src/features/admin/channels/components/GenerateGridDialog.tsx
+++ b/frontend/src/features/admin/channels/components/GenerateGridDialog.tsx
@@ -143,10 +143,17 @@ export function GenerateGridDialog({
)}
+ {/* Замечания разделены по цвету: красное останется дырой в эфире, янтарное —
+ повод посмотреть, но сетка соберётся. */}
{plan.notes.length > 0 && (
-
+
{plan.notes.map((note) => (
- - {note}
+ -
+ {note.text}
+
))}
)}
diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts
index fc1146c..09987d6 100644
--- a/frontend/src/shared/api/types.ts
+++ b/frontend/src/shared/api/types.ts
@@ -726,11 +726,14 @@ export type GridPlanSlotDto = {
block: string
}
+/** Замечание к плану: `isError` — то, что после сборки останется дырой в эфире. */
+export type PlanNoteDto = { isError: boolean; text: string }
+
export type GridPlanDto = {
profile: GridProfileKind
profileName: string
slots: GridPlanSlotDto[]
- notes: string[]
+ notes: PlanNoteDto[]
/** Больше нуля только при пересборке — столько слотов будет снесено. */
slotsToRemove: number
fallbackGroupName: string | null