Refactor GridScheduleGenerator and related components to improve cursor management and group handling
Updated the GridScheduleGenerator to reset cursors for slots with no aired positions, ensuring proper state management during rebuilds. Introduced a new method, ResetUnairedCursorsAsync, to handle cursor resets effectively. Enhanced the handling of interstitial groups in the scheduling process, preventing them from being included in slots or fallback groups. Updated related tests to verify the correct behavior of these changes, ensuring robust scheduling logic and accurate group categorization.
This commit is contained in:
@@ -82,11 +82,13 @@ public sealed class GridScheduleGenerator(
|
||||
|
||||
// Пересборка выбрасывает хвост, который курсоры уже «съели»: без отмотки каждое применение
|
||||
// проматывало бы библиотеку на горизонт вперёд, теряя серии, которые так и не вышли.
|
||||
var aired = new Dictionary<Guid, AiredPosition>();
|
||||
// null здесь значит «отматывать нечего»: обычный тик ничего не сносил.
|
||||
Dictionary<Guid, AiredPosition>? aired = null;
|
||||
if (rebuildFuture)
|
||||
{
|
||||
await DropFutureAsync(channelId, now, cancellationToken);
|
||||
aired = await LoadAiredPositionsAsync(channelId, cancellationToken);
|
||||
await ResetUnairedCursorsAsync(template, aired, cancellationToken);
|
||||
}
|
||||
|
||||
// Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено,
|
||||
@@ -179,7 +181,7 @@ public sealed class GridScheduleGenerator(
|
||||
template,
|
||||
from,
|
||||
horizonEnd,
|
||||
new Dictionary<Guid, AiredPosition>(),
|
||||
null,
|
||||
cancellationToken
|
||||
);
|
||||
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
|
||||
@@ -286,6 +288,29 @@ public sealed class GridScheduleGenerator(
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Забывает позиции слотов, за которыми не стоит ни одного отыгранного выхода. Состояние такого
|
||||
/// слота набрано целиком из хвоста, который только что снесли, и хранить его дальше нельзя:
|
||||
/// прогон его уже игнорирует, но обычный тик потом поднял бы то же самое из базы.
|
||||
/// </summary>
|
||||
private async Task ResetUnairedCursorsAsync(
|
||||
ScheduleTemplate template,
|
||||
IReadOnlyDictionary<Guid, AiredPosition> aired,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var slotIds = template.Layers.SelectMany(l => l.Slots).Select(s => s.Id).ToList();
|
||||
if (slotIds.Count == 0)
|
||||
return;
|
||||
|
||||
var states = await dbContext
|
||||
.SlotStates.Where(s => slotIds.Contains(s.SlotId) && s.CurrentElementId != null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var state in states.Where(s => !aired.ContainsKey(s.SlotId)))
|
||||
state.Reset();
|
||||
}
|
||||
|
||||
private async Task SaveCursorsAsync(
|
||||
IReadOnlyList<PlanningCursorUpdate> cursors,
|
||||
CancellationToken cancellationToken
|
||||
@@ -316,13 +341,13 @@ public sealed class GridScheduleGenerator(
|
||||
}
|
||||
|
||||
/// <param name="aired">Куда слот доиграл в уцелевшей ленте — перебивает снимок состояния при
|
||||
/// пересборке; пусто на обычном тике и в предпросмотре.</param>
|
||||
/// пересборке; null на обычном тике и в предпросмотре, где сносить нечего.</param>
|
||||
private async Task<PlanningInput> BuildInputAsync(
|
||||
Channel channel,
|
||||
ScheduleTemplate template,
|
||||
DateTimeOffset startUtc,
|
||||
DateTimeOffset horizonEnd,
|
||||
IReadOnlyDictionary<Guid, AiredPosition> aired,
|
||||
IReadOnlyDictionary<Guid, AiredPosition>? aired,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
@@ -400,21 +425,7 @@ public sealed class GridScheduleGenerator(
|
||||
: await LoadRepeatUnitsAsync(channel.Id, repeatWindow, startUtc, cancellationToken);
|
||||
|
||||
var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
|
||||
var cursor =
|
||||
(
|
||||
aired.TryGetValue(slot.Id, out var position)
|
||||
? RewoundCursor(position, elements)
|
||||
: null
|
||||
)
|
||||
?? (
|
||||
states.TryGetValue(slot.Id, out var state)
|
||||
? new PlanningCursor(
|
||||
state.CurrentElementKind,
|
||||
state.CurrentElementId,
|
||||
state.NextUnitIndex
|
||||
)
|
||||
: null
|
||||
);
|
||||
var cursor = CursorFor(slot.Id, elements, aired, states);
|
||||
|
||||
planned.Add(
|
||||
item,
|
||||
@@ -603,6 +614,43 @@ public sealed class GridScheduleGenerator(
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// С какой позиции слот продолжает прогон.
|
||||
///
|
||||
/// На обычном тике (<paramref name="aired"/> = null) это снимок состояния из базы. При пересборке
|
||||
/// правда — только уцелевшая лента: снимок считает пройденным весь снесённый хвост, и доверять
|
||||
/// ему нельзя. Слот, у которого в ленте не осталось ни одного отыгранного выхода, начинает
|
||||
/// с начала: иначе повторное «Применить» на канале, который ещё не вышел в эфир, каждый раз
|
||||
/// проматывало бы библиотеку на горизонт вперёд — сериал прыгал бы через серии, а франшиза
|
||||
/// начиналась бы с третьей части.
|
||||
/// </summary>
|
||||
private static PlanningCursor? CursorFor(
|
||||
Guid slotId,
|
||||
IReadOnlyList<PlanningElement> elements,
|
||||
IReadOnlyDictionary<Guid, AiredPosition>? aired,
|
||||
IReadOnlyDictionary<Guid, SlotState> states
|
||||
)
|
||||
{
|
||||
PlanningCursor? stored = states.TryGetValue(slotId, out var state)
|
||||
? new PlanningCursor(
|
||||
state.CurrentElementKind,
|
||||
state.CurrentElementId,
|
||||
state.NextUnitIndex
|
||||
)
|
||||
: null;
|
||||
|
||||
if (aired is null)
|
||||
return stored;
|
||||
|
||||
if (!aired.TryGetValue(slotId, out var position))
|
||||
return null;
|
||||
|
||||
// Отыгранное есть, но той единицы в элементе больше нет (серию удалили, ассет перестал быть
|
||||
// готовым). Отматывать некуда — пусть решает сохранённое состояние: слот в эфире уже был,
|
||||
// и откат в начало группы был бы виден зрителю.
|
||||
return RewoundCursor(position, elements) ?? stored;
|
||||
}
|
||||
|
||||
/// <summary>Последняя запись слота в уцелевшей ленте: элемент и единица, которой он доиграл.</summary>
|
||||
private sealed record AiredPosition(
|
||||
GroupElementKind ElementKind,
|
||||
|
||||
@@ -99,8 +99,12 @@ public sealed class GridPlanner(IAppDbContext dbContext, GroupCatalog groups)
|
||||
.Sum(gap => gap.To - gap.From);
|
||||
|
||||
// Аварийная группа отдаётся отдельным полем, а не замечанием: замечания — про то, что пошло
|
||||
// не так, а это штатное действие, и в предпросмотре у него своя строка.
|
||||
var fallback = candidates.MaxBy(g => g.UnitCount);
|
||||
// не так, а это штатное действие, и в предпросмотре у него своя строка. Ролики сюда не идут
|
||||
// по той же причине, что и в слот: дыру они закрывают рекламой, подписанной как программа,
|
||||
// а по размеру группа роликов почти всегда самая большая на канале.
|
||||
var fallback = candidates
|
||||
.Where(g => g.DominantKind != ShowKind.Interstitial)
|
||||
.MaxBy(g => g.UnitCount);
|
||||
var needsFallback = template.FallbackGroupId is null && fallback is not null;
|
||||
|
||||
var everyDaySpan = EveryDaySpan(mode, profile);
|
||||
@@ -560,6 +564,12 @@ public sealed class GridPlanner(IAppDbContext dbContext, GroupCatalog groups)
|
||||
|
||||
private static bool Allowed(GridBand band, GroupCandidate group)
|
||||
{
|
||||
// Ролики — материал для стыков, а не для эфира (см. 6.7): слот по такой группе выдаёт
|
||||
// в эфир полосу рекламы, подписанную как программа. Отсев жёсткий, до баллов: по размеру
|
||||
// группа роликов бьёт любую библиотеку.
|
||||
if (group.DominantKind == ShowKind.Interstitial)
|
||||
return false;
|
||||
|
||||
// Группа без рейтингов проходит любой потолок — так же ведут себя фильтр набора и
|
||||
// планировщик: источники проставляют рейтинг далеко не всему.
|
||||
if (band.MaxAudience is { } max && group.Strictest is { } strictest && strictest > max)
|
||||
|
||||
@@ -10,6 +10,10 @@ namespace TeleWave.Application.Programming.Templates.Generate;
|
||||
/// Группы канала глазами раскладки: сколько эфира каждая даёт, какой она природы и насколько строгий
|
||||
/// в ней контент. Отдельный тип, а не метод планировщика: те же факты нужны и запросу к ИИ, а считать
|
||||
/// их дважды значит однажды разойтись — сетка собиралась бы по одним числам, а ИИ предлагал по другим.
|
||||
///
|
||||
/// Группы роликов отсюда не выбрасываются: они существуют, их видно в запросе к ИИ, и молчаливое
|
||||
/// исчезновение группы из списка выглядело бы потерей. Не годятся они только в слот — это решает
|
||||
/// раскладка по <see cref="GroupCandidate.DominantKind"/>.
|
||||
/// </summary>
|
||||
public sealed class GroupCatalog(
|
||||
IAppDbContext dbContext,
|
||||
@@ -111,6 +115,12 @@ public sealed class GroupCatalog(
|
||||
|
||||
// У коллекции своего типа нет: франшиза — это почти всегда полнометражки.
|
||||
var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount);
|
||||
// Ролики распознаются первыми: группа рекламы даёт сотни коротких единиц, то есть самый
|
||||
// большой UnitCount из всех, и записанная «полным метром» она побеждала бы по баллам
|
||||
// в любой полосе без предпочтения по типу — в эфир уходила бы полоса рекламы.
|
||||
var interstitialUnits = resolved
|
||||
.Where(r => r.ShowKind == ShowKind.Interstitial)
|
||||
.Sum(r => r.UnitCount);
|
||||
var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration);
|
||||
|
||||
return new GroupCandidate(
|
||||
@@ -119,7 +129,9 @@ public sealed class GroupCatalog(
|
||||
composition.Count,
|
||||
units,
|
||||
resolved.Max(r => r.Audience),
|
||||
seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single,
|
||||
interstitialUnits * 2 >= units ? ShowKind.Interstitial
|
||||
: seriesUnits * 2 >= units ? ShowKind.Series
|
||||
: ShowKind.Single,
|
||||
duration.TotalMinutes / units,
|
||||
primaryGenreId
|
||||
);
|
||||
|
||||
@@ -29,6 +29,8 @@ public static class GridPromptText
|
||||
- Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу
|
||||
импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет.
|
||||
- Стыки и заставки придумывать нельзя — только имена из списков ниже.
|
||||
- Группы типа «ролик» — это реклама и джинглы для стыков. В слоты их ставить нельзя:
|
||||
в эфир уйдёт полоса роликов, подписанная как программа.
|
||||
- Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой
|
||||
с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»).
|
||||
- weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай
|
||||
|
||||
Reference in New Issue
Block a user