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