Add broadcast scheduling features: implement Show and Channel entities, enhance AppDbContext and DependencyInjection for broadcasting, and update API routing. Include migration for new database schema and update documentation for broadcast-related functionalities.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
|
||||
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
|
||||
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
|
||||
/// </summary>
|
||||
public sealed class ScheduleGenerator(
|
||||
IAppDbContext dbContext,
|
||||
IRandomSource random,
|
||||
IOptions<SchedulerOptions> options
|
||||
)
|
||||
{
|
||||
private readonly SchedulerOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
|
||||
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
|
||||
/// </summary>
|
||||
public async Task<int> GenerateAsync(
|
||||
Guid channelId,
|
||||
DateTimeOffset now,
|
||||
bool regenerate,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels
|
||||
.Include(c => c.Shows)
|
||||
.Include(c => c.Ads)
|
||||
.Include(c => c.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||
|
||||
if (channel is null || !channel.IsEnabled)
|
||||
return -1;
|
||||
|
||||
var horizonEnd = now.AddDays(_options.HorizonDays);
|
||||
|
||||
// Чистим прошлое сверх окна ретеншна.
|
||||
var retentionCutoff = now.AddHours(-_options.RetentionHours);
|
||||
await dbContext.ScheduleEntries
|
||||
.Where(e => e.ChannelId == channelId && e.EndsAtUtc < retentionCutoff)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
// Точка продолжения: конец последней сохранённой записи (для regenerate — только уже стартовавшей).
|
||||
var lastEnd = await dbContext.ScheduleEntries
|
||||
.Where(e =>
|
||||
e.ChannelId == channelId && (!regenerate || e.StartsAtUtc < now)
|
||||
)
|
||||
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
|
||||
|
||||
var startTime = lastEnd ?? now;
|
||||
if (startTime < now)
|
||||
startTime = now;
|
||||
|
||||
if (regenerate)
|
||||
await dbContext.ScheduleEntries
|
||||
.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
if (startTime >= horizonEnd)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
|
||||
var result = SchedulePlanner.Plan(input, random);
|
||||
|
||||
foreach (var entry in result.Entries)
|
||||
{
|
||||
var scheduleEntry = entry.Kind == ScheduleEntryKind.Program
|
||||
? ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
entry.ShowId!.Value,
|
||||
entry.EpisodeIndex!.Value
|
||||
)
|
||||
: ScheduleEntry.Ad(channel.Id, entry.MediaAssetId, entry.StartsAtUtc, entry.EndsAtUtc);
|
||||
dbContext.ScheduleEntries.Add(scheduleEntry);
|
||||
}
|
||||
|
||||
foreach (var channelShow in channel.Shows)
|
||||
if (result.NextEpisodeIndexByChannelShow.TryGetValue(channelShow.Id, out var idx))
|
||||
channelShow.SetNextEpisodeIndex(idx);
|
||||
|
||||
channel.SetNextAdIndex(result.NextAdIndex);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return result.Entries.Count;
|
||||
}
|
||||
|
||||
private async Task<PlannerInput> BuildInputAsync(
|
||||
Channel channel,
|
||||
DateTimeOffset startTime,
|
||||
DateTimeOffset horizonEnd,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var enabledShows = channel.Shows.Where(s => s.IsEnabled).ToList();
|
||||
var showIds = enabledShows.Select(s => s.ShowId).Distinct().ToList();
|
||||
|
||||
var shows = await dbContext.Shows
|
||||
.Include(s => s.Episodes)
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var episodesByShow = shows.ToDictionary(
|
||||
s => s.Id,
|
||||
s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList()
|
||||
);
|
||||
|
||||
var candidateAssetIds = episodesByShow.Values
|
||||
.SelectMany(x => x)
|
||||
.Concat(channel.Ads.Select(a => a.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var durations = await dbContext.MediaAssets
|
||||
.Where(a =>
|
||||
candidateAssetIds.Contains(a.Id)
|
||||
&& a.Status == MediaAssetStatus.Ready
|
||||
&& a.Duration != null
|
||||
)
|
||||
.Select(a => new { a.Id, a.Duration })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Duration!.Value, cancellationToken);
|
||||
|
||||
var plannerShows = new List<PlannerShow>();
|
||||
foreach (var channelShow in enabledShows)
|
||||
{
|
||||
if (!episodesByShow.TryGetValue(channelShow.ShowId, out var episodeIds))
|
||||
continue;
|
||||
var ready = episodeIds.Where(durations.ContainsKey).ToList();
|
||||
if (ready.Count == 0)
|
||||
continue;
|
||||
|
||||
plannerShows.Add(
|
||||
new PlannerShow(
|
||||
channelShow.Id,
|
||||
channelShow.ShowId,
|
||||
channelShow.Weight,
|
||||
channelShow.BlockMode,
|
||||
channelShow.BlockValue,
|
||||
ready,
|
||||
channelShow.NextEpisodeIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var adPool = channel.Ads
|
||||
.OrderBy(a => a.Position)
|
||||
.Select(a => a.MediaAssetId)
|
||||
.Where(durations.ContainsKey)
|
||||
.ToList();
|
||||
|
||||
var overrides = channel.Overrides
|
||||
.Select(o => new PlannerOverride(
|
||||
o.StartsAtUtc,
|
||||
o.EndsAtUtc,
|
||||
o.Mode,
|
||||
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return new PlannerInput(
|
||||
channel.Id,
|
||||
channel.AdInsertion,
|
||||
channel.AdsPerBreak,
|
||||
channel.NextAdIndex,
|
||||
plannerShows,
|
||||
adPool,
|
||||
durations,
|
||||
overrides,
|
||||
startTime,
|
||||
horizonEnd
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user