diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs
index 41d1694..5e93f43 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs
@@ -306,6 +306,13 @@ public sealed class GridPlanner(
}
}
+ ///
+ /// Чем закрыть точку сетки. Решение здесь, сборка — в : «что ставим»
+ /// и «как это собрать» читаются по отдельности.
+ ///
+ /// Порядок предпочтений: свежий контент → повтор вечернего блока → контент по второму кругу →
+ /// ничего (место закроет фон).
+ ///
private static PlannedSlot? Compose(
GridBand band,
SlotTarget target,
@@ -315,73 +322,79 @@ public sealed class GridPlanner(
PlanRun run
)
{
+ var factory = new SlotFactory(band, target, offset, anchor, run);
+
if (band.SlotKind == SlotKind.SignOff)
- return Build(band.Title, null, available, SlotBlockMode.FillSlot, 1, null, null);
+ return factory.SignOff(available);
if (band.SlotKind == SlotKind.Repeat)
- return RepeatSlot(band.Title, band.RepeatFrom, available);
+ return factory.Repeat(band.RepeatFrom, available);
var group = Pick(band, target, run, available);
+ if (group is not null && !run.Exhausted(group))
+ return factory.Content(group, available);
- // Ставить нечего — либо подходящей группы нет вовсе, либо свежий контент на неделю кончился.
- // И то и другое закрывается повтором вечернего блока, как на настоящем ТВ: крутить по
- // третьему кругу одно и то же хуже — зритель видит то же самое, но без подписи «повтор».
- if (group is null || run.Exhausted(group))
+ // Свежего контента нет — либо подходящей группы не нашлось вовсе, либо ёмкость на неделю
+ // исчерпана. И то и другое закрывается повтором, как на настоящем ТВ: крутить по третьему
+ // кругу одно и то же хуже — зритель видит то же самое, но без честной подписи «повтор».
+ if (CanRepeat(band, run))
{
- 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))
- );
+ run.Note(RepeatReason(band, group));
+ return factory.Repeat(run.PrimeStart!.Value, Math.Min(available, RepeatMinutes(band)));
}
- return ContentSlot(group);
+ run.Note(Unfillable(band, group), isError: group is null);
+ return group is null ? null : factory.Content(group, available);
+ }
- PlannedSlot ContentSlot(GroupCandidate picked)
+ ///
+ /// Сборка слота в известной точке сетки: полоса, день, слой и смещение уже определены, меняется
+ /// только наполнение. Отдельный тип, а не набор замыканий внутри : там
+ /// принимается решение, здесь — оформляется.
+ ///
+ private readonly record struct SlotFactory(
+ GridBand Band,
+ SlotTarget Target,
+ int Offset,
+ bool Anchor,
+ PlanRun Run
+ )
+ {
+ public PlannedSlot Content(GroupCandidate group, int available)
{
- var (duration, units) = Measure(band, picked, available);
- run.Reserve(picked, units, duration, target.AiringsPerWeek);
+ var (duration, units) = Measure(Band, group, available);
+ Run.Reserve(group, units, duration, Target.AiringsPerWeek);
return Build(
- picked.Name,
- picked.Id,
+ group.Name,
+ group.Id,
duration,
- band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot,
+ Band.UnitsPerBlock > 0 ? SlotBlockMode.Count : SlotBlockMode.FillSlot,
Math.Max(1, units),
new SlotStrategy(
- band.Strategy,
+ Band.Strategy,
RestartOnEnd: true,
- CooldownDays: band.CooldownDays
+ CooldownDays: Band.CooldownDays
),
null
);
}
- PlannedSlot RepeatSlot(string title, TimeOnly repeatFrom, int minutes) =>
+ public PlannedSlot Repeat(TimeOnly from, int minutes) =>
Build(
- title,
+ Band.Title,
null,
minutes,
SlotBlockMode.FillSlot,
1,
null,
- new RepeatSource(band.RepeatDaysAgo, repeatFrom, minutes)
+ new RepeatSource(Band.RepeatDaysAgo, from, minutes)
);
- PlannedSlot Build(
+ public PlannedSlot SignOff(int minutes) =>
+ Build(Band.Title, null, minutes, SlotBlockMode.FillSlot, 1, null, null);
+
+ private PlannedSlot Build(
string title,
Guid? groupId,
int duration,
@@ -391,23 +404,30 @@ public sealed class GridPlanner(
RepeatSource? repeat
) =>
new(
- target.Layer,
- target.Weekday,
- GridCoverage.AtOffset(offset, run.DayStart),
+ Target.Layer,
+ Target.Weekday,
+ GridCoverage.AtOffset(Offset, Run.DayStart),
duration,
title,
- band.Daypart,
- repeat is null ? band.SlotKind : SlotKind.Repeat,
+ Band.Daypart,
+ repeat is null ? Band.SlotKind : SlotKind.Repeat,
groupId,
groupId is null ? null : title,
blockMode,
blockValue,
strategy,
repeat,
- anchor
+ Anchor
);
}
+ /// Почему полосу закрыл повтор — текст для предпросмотра.
+ private static string RepeatReason(GridBand band, GroupCandidate? group) =>
+ group is null
+ ? $"Для полосы «{band.Title}» подходящей группы нет — её закрывает повтор вечернего блока."
+ : $"Полосу «{band.Title}» закрывает повтор вечернего блока: свежего контента на неделю "
+ + "не хватает.";
+
/// Почему полоса осталась без свежего контента — текст для предпросмотра.
private static string Unfillable(GridBand band, GroupCandidate? group) =>
group is null
diff --git a/frontend/src/features/admin/media/episode-parse.ts b/frontend/src/features/admin/media/episode-parse.ts
index 3c4ff75..a5a7c51 100644
--- a/frontend/src/features/admin/media/episode-parse.ts
+++ b/frontend/src/features/admin/media/episode-parse.ts
@@ -77,15 +77,17 @@ export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpiso
// и движок перебирает варианты впустую (у Sonar это «super-linear backtracking»).
const SEASON_RANGES = [
/(?:s|season|сезоны?)[\s._-]*\d{1,2}[\s._]*[-–][\s._]*(?:s|season)?\d{1,2}/i,
- /\d{1,2}[\s._]*[-–][\s._]*\d{1,2}[\s._-]*сезон/i,
+ /\d{1,2}[\s._]*[-–][\s._]*\d{1,2}[\s._-]*(?:season|сезон)/i,
]
-/** Сезон в имени одного сегмента пути: «S02», «Season 2», «Сезон 2», «2 сезон». */
+/** Сезон в имени одного сегмента пути: «S02», «Season 2», «Сезон 2», «2 сезон», «3 Season». */
const SEASON_PATTERNS = [
// S02 — не часть более длинного числа (год «S2007» сезоном не считаем).
/(?:^|[^a-z0-9])s[\s._-]*(\d{1,2})(?!\d)/i,
/(?:season|сезон)[\s._-]*(\d{1,2})(?!\d)/i,
- /(\d{1,2})[\s._-]*сезон/i,
+ // Номер перед словом — «3 Season», «2 сезон». Ограничение слева не даёт принять за сезон
+ // хвост года: в «2007 Season» это не третий сезон, а сезон 2007 года.
+ /(?:^|[^\d])(\d{1,2})[\s._-]*(?:season|сезон)/i,
]
/**