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, ] /**