diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
index 0c74072..ad33cd6 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
@@ -64,20 +64,8 @@ public sealed class GridScheduleGenerator(
if (channel is null)
return new GenerationReport(0, [], ChannelSkipped: true);
- // Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке
- // «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет,
- // не должно остаться и будущих записей. Иначе они висят вечно — пересобрать их нечем,
- // а удалить шоу, которое в них стоит, невозможно.
if (!channel.IsEnabled || channel.TemplateId is null)
- {
- if (!rebuildFuture)
- return new GenerationReport(0, [], ChannelSkipped: true);
-
- await dbContext
- .ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
- .ExecuteDeleteAsync(cancellationToken);
- return new GenerationReport(0, []);
- }
+ return await SilentChannelAsync(channelId, now, rebuildFuture, cancellationToken);
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
await dbContext.AcquireChannelLockAsync(channelId, cancellationToken);
@@ -93,9 +81,7 @@ public sealed class GridScheduleGenerator(
await CleanupAsync(channelId, now, cancellationToken);
if (rebuildFuture)
- await dbContext
- .ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
- .ExecuteDeleteAsync(cancellationToken);
+ await DropFutureAsync(channelId, now, cancellationToken);
// Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено,
// поэтому она укажет на границу неизменяемого прошлого.
@@ -139,37 +125,7 @@ public sealed class GridScheduleGenerator(
cancellationToken
);
- var added = 0;
- for (var index = 0; index < result.Items.Count; index++)
- {
- var item = result.Items[index];
- var assetId = item.MediaAssetId;
- if (
- item.Kind == PlannedItemKind.Bumper
- && !bumperAssets.TryGetValue(index, out assetId)
- )
- continue; // Без ассета запись стала бы дырой в ленте.
-
- dbContext.ScheduleEntries.Add(
- ScheduleEntry.FromSlot(
- channel.Id,
- assetId,
- ToEntryKind(item.Kind),
- item.StartsAtUtc,
- item.EndsAtUtc,
- new ScheduleEntryOrigin(
- item.ShowId,
- item.UnitIndex,
- item.SlotId,
- item.Trace is null
- ? null
- : JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
- item.CollectionId
- )
- )
- );
- added++;
- }
+ var added = WriteEntries(channel.Id, result.Items, bumperAssets);
await SaveCursorsAsync(result.Cursors, cancellationToken);
@@ -231,6 +187,82 @@ public sealed class GridScheduleGenerator(
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
/// история показов берётся из самой ленты, отдельного журнала нет.
///
+ ///
+ /// Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке
+ /// «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет,
+ /// не должно остаться и будущих записей. Иначе они висят вечно — пересобрать их нечем,
+ /// а удалить шоу, которое в них стоит, невозможно.
+ ///
+ private async Task SilentChannelAsync(
+ Guid channelId,
+ DateTimeOffset now,
+ bool rebuildFuture,
+ CancellationToken cancellationToken
+ )
+ {
+ if (!rebuildFuture)
+ return new GenerationReport(0, [], ChannelSkipped: true);
+
+ await DropFutureAsync(channelId, now, cancellationToken);
+ return new GenerationReport(0, []);
+ }
+
+ /// Снимает будущий хвост ленты. Прошлое и идущую запись не трогает никогда.
+ private Task DropFutureAsync(
+ Guid channelId,
+ DateTimeOffset now,
+ CancellationToken cancellationToken
+ ) =>
+ dbContext
+ .ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ ///
+ /// Переносит собранную ленту в записи расписания. Заставка без отрендеренного ассета
+ /// пропускается: запись без файла стала бы дырой в эфире.
+ ///
+ private int WriteEntries(
+ Guid channelId,
+ IReadOnlyList items,
+ IReadOnlyDictionary bumperAssets
+ )
+ {
+ var added = 0;
+
+ for (var index = 0; index < items.Count; index++)
+ {
+ var item = items[index];
+ var assetId = item.MediaAssetId;
+ if (
+ item.Kind == PlannedItemKind.Bumper
+ && !bumperAssets.TryGetValue(index, out assetId)
+ )
+ continue;
+
+ dbContext.ScheduleEntries.Add(
+ ScheduleEntry.FromSlot(
+ channelId,
+ assetId,
+ ToEntryKind(item.Kind),
+ item.StartsAtUtc,
+ item.EndsAtUtc,
+ new ScheduleEntryOrigin(
+ item.ShowId,
+ item.UnitIndex,
+ item.SlotId,
+ item.Trace is null
+ ? null
+ : JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
+ item.CollectionId
+ )
+ )
+ );
+ added++;
+ }
+
+ return added;
+ }
+
private Task CleanupAsync(
Guid channelId,
DateTimeOffset now,