Add interstitial endpoints and enhance media and template functionalities: implement MapInterstitialEndpoints in Program.cs, add preview functionality for media assets in MediaEndpoints, and introduce template preview capabilities in TemplateEndpoints. Remove obsolete bumper settings from Channel and related classes to streamline configuration.
build / backend (push) Successful in 5m58s
build / frontend (push) Successful in 47s
tests / backend-tests (push) Successful in 5m57s

This commit is contained in:
Leonid Pershin
2026-07-26 14:03:53 +03:00
parent 66040a8841
commit 7b12a06d1b
53 changed files with 4076 additions and 469 deletions
@@ -146,6 +146,39 @@ public sealed class GridScheduleGenerator(
return new GenerationReport(added, result.Warnings);
}
/// <summary>
/// Сухой прогон: считает, каким получился бы эфир по текущим правилам, но ничего не пишет —
/// ни ленты, ни курсоров слотов, ни отметки о применении. Заставки остаются резервом известной
/// длины: рендер долгий и может упасть, поэтому он делается только при реальном применении.
/// </summary>
public async Task<PlanningResult?> PreviewAsync(
Guid channelId,
DateTimeOffset from,
int days,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
if (channel is null || channel.TemplateId is null)
return null;
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken);
if (template is null)
return null;
var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken);
return Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
}
/// <summary>
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
/// история показов берётся из самой ленты, отдельного журнала нет.
@@ -0,0 +1,33 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Preview;
/// <summary>
/// Прогон генератора по текущим правилам без записи: что было бы в эфире, если применить сейчас.
/// Курсоры слотов не двигаются, поэтому предпросмотр можно жать сколько угодно раз.
/// </summary>
public sealed record PreviewScheduleQuery(Guid ChannelId, int Days = 1)
: IQuery<Result<SchedulePreviewDto>>;
/// <summary>Запись предполагаемой ленты. Заставка приходит без ассета — он рендерится при применении.</summary>
public sealed record PreviewItemDto(
PlannedItemKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? Title,
Guid? SlotId,
string? SlotTitle
);
public sealed record SchedulePreviewDto(
DateTimeOffset FromUtc,
DateTimeOffset ToUtc,
/// <summary>Время канала: лента отдаётся в UTC, а показывается в нём.</summary>
int UtcOffsetMinutes,
IReadOnlyList<PreviewItemDto> Items,
IReadOnlyList<PlanningWarningDto> Warnings
);
@@ -0,0 +1,112 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Preview;
public sealed class PreviewScheduleQueryHandler(
IAppDbContext dbContext,
GridScheduleGenerator generator
) : IQueryHandler<PreviewScheduleQuery, Result<SchedulePreviewDto>>
{
public async Task<Result<SchedulePreviewDto>> Handle(
PreviewScheduleQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == query.ChannelId)
.Select(c => new { c.UtcOffsetMinutes })
.FirstOrDefaultAsync(cancellationToken);
if (channel is null)
return Result.Failure<SchedulePreviewDto>(ChannelErrors.NotFound);
var from = DateTimeOffset.UtcNow;
var result = await generator.PreviewAsync(
query.ChannelId,
from,
query.Days,
cancellationToken
);
if (result is null)
return Result.Failure<SchedulePreviewDto>(ChannelErrors.TemplateNotFound);
var showNames = await LoadShowNamesAsync(result.Items, cancellationToken);
var slotTitles = await LoadSlotTitlesAsync(result.Items, cancellationToken);
var items = result
.Items.Select(item => new PreviewItemDto(
item.Kind,
item.StartsAtUtc,
item.EndsAtUtc,
item.ShowId,
item.ShowId is { } showId && showNames.TryGetValue(showId, out var name)
? name
: null,
item.SlotId,
item.SlotId is { } slotId && slotTitles.TryGetValue(slotId, out var title)
? title
: null
))
.ToList();
var to = items.Count == 0 ? from : items[^1].EndsAtUtc;
return Result.Success(
new SchedulePreviewDto(
from,
to,
channel.UtcOffsetMinutes,
items,
result
.Warnings.Select(w => new PlanningWarningDto(w.Kind, w.SlotId, w.Details))
.ToList()
)
);
}
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
IReadOnlyList<PlannedItem> items,
CancellationToken cancellationToken
)
{
var ids = items
.Select(i => i.ShowId)
.Where(id => id is not null && id != Guid.Empty)
.Select(id => id!.Value)
.Distinct()
.ToList();
if (ids.Count == 0)
return [];
return await dbContext
.Shows.AsNoTracking()
.Where(s => ids.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
}
private async Task<Dictionary<Guid, string>> LoadSlotTitlesAsync(
IReadOnlyList<PlannedItem> items,
CancellationToken cancellationToken
)
{
var ids = items
.Select(i => i.SlotId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
if (ids.Count == 0)
return [];
return await dbContext
.Slots.AsNoTracking()
.Where(s => ids.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken);
}
}