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 — слот идёт каждый день. Предпочитай
|
||||
|
||||
@@ -475,23 +475,32 @@ public static class SchedulePlanner
|
||||
{
|
||||
// Шов до круглой отметки и до якоря по замыслу занимает реклама: «ещё одна серия вместо
|
||||
// рекламы» — первое, что выдаёт в эфире плейлист вместо телеканала.
|
||||
var cursor = padFirst ? FillWithPad(from, until, run, slotId) : from;
|
||||
var maxPad = run.Input.MaxPadDuration;
|
||||
var cursor = padFirst ? FillWithPad(from, until, run, slotId, maxPad) : from;
|
||||
|
||||
cursor = FillWithBackground(cursor, until, run);
|
||||
// Остаток, куда программа уже не влезает, — снова шов, и он тоже рекламный.
|
||||
cursor = FillWithPad(cursor, until, run, slotId);
|
||||
var afterBackground = FillWithBackground(cursor, until, run);
|
||||
|
||||
// Остаток, куда программа уже не влезает, — снова шов, и он тоже рекламный. Потолок считается
|
||||
// от последней программы, а не от каждого добора по отдельности: без фона оба добора сходятся
|
||||
// встык, и в эфир вместо десяти минут рекламы уходит двадцать.
|
||||
var left = afterBackground > cursor ? maxPad : maxPad - (cursor - from);
|
||||
cursor = FillWithPad(afterBackground, until, run, slotId, left);
|
||||
return FillWithFallback(cursor, until, run, slotId);
|
||||
}
|
||||
|
||||
/// <summary>Ставит ролики и анонсы, пока влезают и пока не исчерпан потолок одного добора.</summary>
|
||||
/// <summary>Ставит ролики и анонсы, пока влезают и пока не исчерпан <paramref name="budget"/>.</summary>
|
||||
private static DateTimeOffset FillWithPad(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset until,
|
||||
PlanningRun run,
|
||||
Guid? slotId
|
||||
Guid? slotId,
|
||||
TimeSpan budget
|
||||
)
|
||||
{
|
||||
var limit = Min(until, from + run.Input.MaxPadDuration);
|
||||
if (budget <= TimeSpan.Zero)
|
||||
return from;
|
||||
|
||||
var limit = Min(until, from + budget);
|
||||
var cursor = from;
|
||||
var guard = 0;
|
||||
|
||||
|
||||
@@ -228,6 +228,44 @@ public class GenerateGridTests
|
||||
Assert.Equal("Сериалы", morning.GroupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InterstitialGroup_GoesNeitherIntoSlots_NorIntoFallback()
|
||||
{
|
||||
// Группа рекламы — материал для стыков. По размеру она бьёт любую библиотеку, поэтому
|
||||
// раньше выигрывала каждую полосу без предпочтения по типу и становилась аварийной:
|
||||
// в эфир уходила полоса роликов, подписанная как программа.
|
||||
var fixture = new TestDb();
|
||||
var series = Playable("Сериал", ShowKind.Series, 40, 25);
|
||||
var films = Films(8, 100);
|
||||
var ads = Enumerable
|
||||
.Range(0, 60)
|
||||
.Select(i => Playable($"Ролик {i}", ShowKind.Interstitial, 1, 1))
|
||||
.ToList();
|
||||
|
||||
var adsGroup = GroupOf("Реклама", [.. ads.Select(a => a.Show.Id)]);
|
||||
var (channelId, _) = await SeedChannelAsync(
|
||||
fixture,
|
||||
[series.Show, .. films.Shows, .. ads.Select(a => a.Show)],
|
||||
[.. series.Assets, .. films.Assets, .. ads.SelectMany(a => a.Assets)],
|
||||
[
|
||||
GroupOf("Сериалы", series.Show.Id),
|
||||
GroupOf("Фильмы", [.. films.Shows.Select(s => s.Id)]),
|
||||
adsGroup,
|
||||
]
|
||||
);
|
||||
|
||||
var plan = await PlanAsync(
|
||||
fixture,
|
||||
channelId,
|
||||
GridProfileKind.Mixed,
|
||||
GridGenerationMode.Rebuild
|
||||
);
|
||||
|
||||
Assert.DoesNotContain(plan.Slots, s => s.GroupId == adsGroup.Id);
|
||||
Assert.NotNull(plan.FallbackGroupId);
|
||||
Assert.NotEqual(adsGroup.Id, plan.FallbackGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChildrenBand_SkipsAdultGroup()
|
||||
{
|
||||
|
||||
@@ -559,6 +559,37 @@ public class SchedulePlannerTests
|
||||
Assert.Contains(result.Items, i => i.SlotId == background.SlotId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pad_CapCountsWholeSeam_WhenBackgroundHasNothingToPlay()
|
||||
{
|
||||
// Часовая дыра и пустой фоновый слой: добор до якоря и остаток перед ним сходятся встык,
|
||||
// и без общего потолка в эфир ушло бы двадцать минут рекламы подряд вместо десяти.
|
||||
var result = Run(
|
||||
Input(
|
||||
[Slot(T0.AddHours(1), 30, [Element(1, 30)], isAnchor: true)],
|
||||
horizonHours: 2,
|
||||
pad: [Ad(5)],
|
||||
maxPad: TimeSpan.FromMinutes(10)
|
||||
)
|
||||
);
|
||||
|
||||
// Считается самый длинный рекламный блок подряд: два добора по разные стороны от фона —
|
||||
// это два шва, а сошедшиеся встык — один, и он потолком и ограничен.
|
||||
var longest = TimeSpan.Zero;
|
||||
var current = TimeSpan.Zero;
|
||||
foreach (var item in result.Items.OrderBy(i => i.StartsAtUtc))
|
||||
{
|
||||
current =
|
||||
item.Kind == PlannedItemKind.Ad
|
||||
? current + (item.EndsAtUtc - item.StartsAtUtc)
|
||||
: TimeSpan.Zero;
|
||||
if (current > longest)
|
||||
longest = current;
|
||||
}
|
||||
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), longest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pad_ClosesTailWhenProgrammeDoesNotFit()
|
||||
{
|
||||
|
||||
@@ -163,6 +163,46 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
|
||||
Assert.Equal(aired.EpisodeIndex + 1, firstAfterRebuild.EpisodeIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Канал, который ещё не вышел в эфир: применять сетку можно сколько угодно раз, и каждый раз
|
||||
/// эфир обязан начинаться с начала. Отматывать здесь не к чему — отыгранного нет вовсе, — и без
|
||||
/// сброса курсора второе применение начинало бы сериал с той серии, до которой курсор домотал
|
||||
/// по только что снесённому хвосту.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Generate_Rebuild_StartsOver_WhenNothingHasAiredYet()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seedDb = fixture.CreateContext();
|
||||
var world = await SeedAsync(seedDb, episodes: 100);
|
||||
|
||||
await using var first = fixture.CreateContext();
|
||||
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
|
||||
|
||||
await using var beforeDb = fixture.CreateContext();
|
||||
// Ничего не отыграно: вся построенная лента — будущее.
|
||||
Assert.Empty(
|
||||
beforeDb.ScheduleEntries.Where(e =>
|
||||
e.ChannelId == world.ChannelId && e.StartsAtUtc < Now
|
||||
)
|
||||
);
|
||||
Assert.True(beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId).NextUnitIndex > 0);
|
||||
|
||||
await using var rebuild = fixture.CreateContext();
|
||||
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var firstProgram = verify
|
||||
.ScheduleEntries.Where(e =>
|
||||
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
|
||||
)
|
||||
.OrderBy(e => e.StartsAtUtc)
|
||||
.First();
|
||||
|
||||
Assert.Equal(0, firstProgram.EpisodeIndex);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user