601 lines
24 KiB
C#
601 lines
24 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using TeleWave.Application.Broadcast.Bumpers;
|
|
using TeleWave.Application.Common.Interfaces;
|
|
using TeleWave.Application.Streaming;
|
|
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,
|
|
IBumperRenderer bumperRenderer,
|
|
IBumperTemplateStorage bumperStorage,
|
|
IImageStore imageStore,
|
|
IOptions<SchedulerOptions> options,
|
|
IOptions<BumperOptions> bumperOptions,
|
|
IOptions<StreamingOptions> streamingOptions,
|
|
ILogger<ScheduleGenerator> logger
|
|
)
|
|
{
|
|
private readonly SchedulerOptions _options = options.Value;
|
|
private readonly BumperOptions _bumper = bumperOptions.Value;
|
|
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
|
|
|
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
|
|
private const int DefaultBumperDurationSeconds = 8;
|
|
|
|
/// <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)
|
|
.ThenInclude(s => s.PreferredHours)
|
|
.Include(c => c.Ads)
|
|
.Include(c => c.BumperTemplates)
|
|
.ThenInclude(t => t.Variants)
|
|
.Include(c => c.Overrides)
|
|
.ThenInclude(o => o.Shows)
|
|
.AsSplitQuery()
|
|
.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 showNames = await LoadShowNamesAsync(channel, cancellationToken);
|
|
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
|
|
var result = SchedulePlanner.Plan(input, random);
|
|
|
|
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + блоку).
|
|
var bumperAssets = await ResolveBumperAssetsAsync(
|
|
channel,
|
|
result.Entries,
|
|
showNames,
|
|
cancellationToken
|
|
);
|
|
|
|
var added = 0;
|
|
foreach (var entry in result.Entries)
|
|
{
|
|
ScheduleEntry? scheduleEntry = entry.Kind switch
|
|
{
|
|
ScheduleEntryKind.Program => ScheduleEntry.Program(
|
|
channel.Id,
|
|
entry.MediaAssetId,
|
|
entry.StartsAtUtc,
|
|
entry.EndsAtUtc,
|
|
entry.ShowId!.Value,
|
|
entry.EpisodeIndex!.Value
|
|
),
|
|
ScheduleEntryKind.Ad => ScheduleEntry.Ad(
|
|
channel.Id,
|
|
entry.MediaAssetId,
|
|
entry.StartsAtUtc,
|
|
entry.EndsAtUtc
|
|
),
|
|
ScheduleEntryKind.Bumper => BuildBumperEntry(channel.Id, entry, bumperAssets),
|
|
_ => null,
|
|
};
|
|
|
|
if (scheduleEntry is null)
|
|
continue;
|
|
|
|
dbContext.ScheduleEntries.Add(scheduleEntry);
|
|
added++;
|
|
}
|
|
|
|
foreach (var channelShow in channel.Shows)
|
|
if (result.NextEpisodeIndexByChannelShow.TryGetValue(channelShow.Id, out var idx))
|
|
channelShow.SetNextEpisodeIndex(idx);
|
|
|
|
channel.SetNextAdIndex(result.NextAdIndex);
|
|
channel.SetNextBumperIndex(result.NextBumperIndex);
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return added;
|
|
}
|
|
|
|
private static ScheduleEntry? BuildBumperEntry(
|
|
Guid channelId,
|
|
PlannedEntry entry,
|
|
IReadOnlyDictionary<(Guid From, Guid To, Guid Variant), Guid> bumperAssets
|
|
)
|
|
{
|
|
// Заставка резолвится по паре шоу + выбранному подблоку (отрендерена/из кэша). Если рендер не
|
|
// удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл).
|
|
if (
|
|
entry.FromShowId is not { } from
|
|
|| entry.ToShowId is not { } to
|
|
|| entry.BumperVariantId is not { } variant
|
|
|| !bumperAssets.TryGetValue((from, to, variant), out var assetId)
|
|
)
|
|
return null;
|
|
|
|
return ScheduleEntry.Bumper(
|
|
channelId,
|
|
assetId,
|
|
entry.StartsAtUtc,
|
|
entry.EndsAtUtc,
|
|
entry.ToShowId,
|
|
entry.BumperVariantId
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового
|
|
/// ассета-заставки: из кэша (<see cref="BumperAsset"/>) либо свежесгенерированного.
|
|
/// </summary>
|
|
private async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveBumperAssetsAsync(
|
|
Channel channel,
|
|
IReadOnlyList<PlannedEntry> entries,
|
|
IReadOnlyDictionary<Guid, string> showNames,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var result = new Dictionary<(Guid, Guid, Guid), Guid>();
|
|
var templatesById = channel.BumperTemplates.ToDictionary(t => t.Id);
|
|
var variantsById = channel.BumperTemplates
|
|
.SelectMany(t => t.Variants.Select(v => (Variant: v, Template: t)))
|
|
.ToDictionary(x => x.Variant.Id);
|
|
var combos = entries
|
|
.Where(e =>
|
|
e.Kind == ScheduleEntryKind.Bumper
|
|
&& e.FromShowId is not null
|
|
&& e.ToShowId is not null
|
|
&& e.BumperVariantId is not null
|
|
)
|
|
.Select(e => (
|
|
From: e.FromShowId!.Value,
|
|
To: e.ToShowId!.Value,
|
|
Variant: e.BumperVariantId!.Value
|
|
))
|
|
.Distinct()
|
|
.ToList();
|
|
if (combos.Count == 0)
|
|
return result;
|
|
|
|
var fromIds = combos.Select(c => c.From).Distinct().ToList();
|
|
var toIds = combos.Select(c => c.To).Distinct().ToList();
|
|
|
|
// Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет
|
|
// своей фон-картинки. Резолвим id постера → расширение → абсолютный путь.
|
|
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
|
var posterShows = await dbContext.Shows.AsNoTracking()
|
|
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
|
|
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
|
|
.ToListAsync(cancellationToken);
|
|
var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList();
|
|
var posterExtById = await dbContext.Images.AsNoTracking()
|
|
.Where(i => posterImageIds.Contains(i.Id))
|
|
.Select(i => new { i.Id, i.FileExtension })
|
|
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
|
|
var posterByShow = new Dictionary<Guid, (Guid ImageId, string AbsPath)>();
|
|
foreach (var p in posterShows)
|
|
if (
|
|
posterExtById.TryGetValue(p.ImageId, out var ext)
|
|
&& imageStore.ResolvePath(p.ImageId, ext) is { } abs
|
|
)
|
|
posterByShow[p.Id] = (p.ImageId, abs);
|
|
|
|
// Фон-картинки блоков (из реестра) — абсолютные пути по id.
|
|
var bgImageIds = channel.BumperTemplates
|
|
.Where(t => t.BackgroundImageId != null)
|
|
.Select(t => t.BackgroundImageId!.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
var bgExtById = await dbContext.Images.AsNoTracking()
|
|
.Where(i => bgImageIds.Contains(i.Id))
|
|
.Select(i => new { i.Id, i.FileExtension })
|
|
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
|
|
var bgByTemplate = new Dictionary<Guid, string>();
|
|
foreach (var t in channel.BumperTemplates)
|
|
if (
|
|
t.BackgroundImageId is { } bgId
|
|
&& bgExtById.TryGetValue(bgId, out var bgExt)
|
|
&& imageStore.ResolvePath(bgId, bgExt) is { } bgAbs
|
|
)
|
|
bgByTemplate[t.Id] = bgAbs;
|
|
|
|
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
|
|
var cached = await dbContext.BumperAssets.AsNoTracking()
|
|
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
|
.Select(b => new
|
|
{
|
|
b.FromShowId,
|
|
b.ToShowId,
|
|
b.Signature,
|
|
b.MediaAssetId,
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
|
|
var readyAssetIds = await dbContext.MediaAssets.AsNoTracking()
|
|
.Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
|
|
.Select(a => a.Id)
|
|
.ToListAsync(cancellationToken);
|
|
var readySet = readyAssetIds.ToHashSet();
|
|
|
|
foreach (var combo in combos)
|
|
{
|
|
if (!variantsById.TryGetValue(combo.Variant, out var pair))
|
|
continue;
|
|
var (variant, template) = pair;
|
|
|
|
var fromName = showNames.GetValueOrDefault(combo.From, "…");
|
|
var toName = showNames.GetValueOrDefault(combo.To, "…");
|
|
// Постер шоу-получателя как фон — только для «Сейчас/Далее» (свободный текст шоу не упоминает).
|
|
var usePoster = variant.Kind == BumperTextKind.NowNext;
|
|
var poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default;
|
|
var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString();
|
|
var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath;
|
|
var bgAbs = bgByTemplate.GetValueOrDefault(template.Id);
|
|
var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template));
|
|
var signature = ComputeSignature(channel, template, variant, fromName, toName, aligned, posterToken);
|
|
|
|
var hit = cached.FirstOrDefault(c =>
|
|
c.FromShowId == combo.From
|
|
&& c.ToShowId == combo.To
|
|
&& c.Signature == signature
|
|
&& readySet.Contains(c.MediaAssetId)
|
|
);
|
|
if (hit is not null)
|
|
{
|
|
result[combo] = hit.MediaAssetId;
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var assetId = await RenderBumperAsync(
|
|
channel,
|
|
template,
|
|
variant,
|
|
combo.From,
|
|
combo.To,
|
|
fromName,
|
|
toName,
|
|
aligned,
|
|
signature,
|
|
posterAbs,
|
|
bgAbs,
|
|
cancellationToken
|
|
);
|
|
result[combo] = assetId;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(
|
|
ex,
|
|
"Не удалось отрендерить заставку {From} → {To}",
|
|
fromName,
|
|
toName
|
|
);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task<Guid> RenderBumperAsync(
|
|
Channel channel,
|
|
BumperTemplate template,
|
|
BumperTextVariant variant,
|
|
Guid fromShowId,
|
|
Guid toShowId,
|
|
string fromName,
|
|
string toName,
|
|
int alignedDurationSeconds,
|
|
string signature,
|
|
string? posterAbsolutePath,
|
|
string? backgroundAbsolutePath,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
|
var render = await bumperRenderer.RenderAsync(
|
|
asset.Id,
|
|
BuildSpec(
|
|
channel,
|
|
template,
|
|
variant,
|
|
alignedDurationSeconds,
|
|
fromName,
|
|
toName,
|
|
posterAbsolutePath,
|
|
backgroundAbsolutePath
|
|
),
|
|
cancellationToken
|
|
);
|
|
|
|
asset.MarkReady(
|
|
render.Duration,
|
|
render.SegmentSeconds,
|
|
render.SegmentCount,
|
|
render.Width,
|
|
render.Height,
|
|
"h264",
|
|
"aac",
|
|
render.RelativePath
|
|
);
|
|
|
|
dbContext.MediaAssets.Add(asset);
|
|
dbContext.BumperAssets.Add(
|
|
BumperAsset.Create(fromShowId, toShowId, signature, asset.Id)
|
|
);
|
|
return asset.Id;
|
|
}
|
|
|
|
private BumperRenderSpec BuildSpec(
|
|
Channel channel,
|
|
BumperTemplate template,
|
|
BumperTextVariant variant,
|
|
int alignedDurationSeconds,
|
|
string fromName,
|
|
string toName,
|
|
string? posterAbsolutePath,
|
|
string? backgroundAbsolutePath
|
|
)
|
|
{
|
|
var free = variant.Kind == BumperTextKind.Free;
|
|
return new BumperRenderSpec(
|
|
alignedDurationSeconds,
|
|
_bumper.Width,
|
|
_bumper.Height,
|
|
template.BackgroundColor,
|
|
template.BackgroundColor2,
|
|
template.AccentColor,
|
|
template.TextColor,
|
|
FontPath(channel.BumperFont),
|
|
free ? "" : variant.NowLabel,
|
|
free ? "" : fromName,
|
|
free ? "" : variant.NextLabel,
|
|
free ? "" : toName,
|
|
backgroundAbsolutePath,
|
|
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
|
posterAbsolutePath,
|
|
free,
|
|
variant.Line1,
|
|
variant.Line2
|
|
);
|
|
}
|
|
|
|
private string FontPath(BumperFont font) =>
|
|
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
|
|
|
/// <summary>Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза.</summary>
|
|
private static double TemplateDurationSeconds(BumperTemplate template) =>
|
|
template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds;
|
|
|
|
/// <summary>Длительность, выровненная вверх до кратности длине сегмента (инвариант раздачи).</summary>
|
|
private int AlignedDurationSeconds(double seconds)
|
|
{
|
|
var requested = Math.Max(_segmentSeconds, seconds);
|
|
return (int)(Math.Ceiling(requested / _segmentSeconds) * _segmentSeconds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
|
|
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
|
|
/// заставка пересобирается.
|
|
/// </summary>
|
|
private string ComputeSignature(
|
|
Channel channel,
|
|
BumperTemplate template,
|
|
BumperTextVariant variant,
|
|
string fromName,
|
|
string toName,
|
|
int alignedDurationSeconds,
|
|
string poster
|
|
)
|
|
{
|
|
var raw = string.Join(
|
|
'',
|
|
_bumper.TemplateVersion,
|
|
_bumper.Width,
|
|
_bumper.Height,
|
|
alignedDurationSeconds,
|
|
channel.BumperFont,
|
|
variant.Kind,
|
|
variant.NowLabel,
|
|
variant.NextLabel,
|
|
variant.Line1,
|
|
variant.Line2,
|
|
template.BackgroundColor,
|
|
template.BackgroundColor2,
|
|
template.AccentColor,
|
|
template.TextColor,
|
|
template.Revision,
|
|
template.BackgroundImageId?.ToString() ?? "-",
|
|
template.AudioExtension ?? "-",
|
|
fromName,
|
|
toName,
|
|
poster
|
|
);
|
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
|
return Convert.ToHexString(hash);
|
|
}
|
|
|
|
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
|
Channel channel,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList();
|
|
if (showIds.Count == 0)
|
|
return new Dictionary<Guid, string>();
|
|
|
|
return await dbContext.Shows.AsNoTracking()
|
|
.Where(s => showIds.Contains(s.Id))
|
|
.Select(s => new { s.Id, s.Name })
|
|
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
|
}
|
|
|
|
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,
|
|
channelShow.PreferredHours
|
|
.Select(h => new PlannerHourWindow(h.StartHour, h.EndHour))
|
|
.ToList(),
|
|
channelShow.PreferredWeightMultiplier
|
|
)
|
|
);
|
|
}
|
|
|
|
var adPool = channel.Ads
|
|
.OrderBy(a => a.Position)
|
|
.Select(a => a.MediaAssetId)
|
|
.Where(durations.ContainsKey)
|
|
.ToList();
|
|
|
|
// Подблоки заставок (плоский список): длительность слота — по звуку блока, выровнена на сегмент.
|
|
var bumperVariants = channel.BumperTemplates
|
|
.OrderBy(t => t.Position)
|
|
.SelectMany(t =>
|
|
{
|
|
var dur = TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t)));
|
|
return t.Variants
|
|
.OrderBy(v => v.Position)
|
|
.Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight));
|
|
})
|
|
.ToList();
|
|
|
|
var overrides = channel.Overrides
|
|
.Select(o => new PlannerOverride(
|
|
o.Mode,
|
|
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(),
|
|
o.Recurrence,
|
|
o.StartsAtUtc,
|
|
o.EndsAtUtc,
|
|
o.DayOfWeek,
|
|
o.StartMinute,
|
|
o.EndMinute
|
|
))
|
|
.ToList();
|
|
|
|
var bumpers = new PlannerBumperConfig(
|
|
channel.BumpersEnabled,
|
|
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
|
|
channel.BumperSelection,
|
|
bumperVariants,
|
|
channel.BumperShowChangeChance,
|
|
channel.BumperEpisodeChangeChance
|
|
);
|
|
|
|
return new PlannerInput(
|
|
channel.Id,
|
|
channel.AdInsertion,
|
|
channel.AdsPerBreak,
|
|
channel.NextAdIndex,
|
|
plannerShows,
|
|
adPool,
|
|
durations,
|
|
overrides,
|
|
startTime,
|
|
horizonEnd,
|
|
bumpers,
|
|
channel.NextBumperIndex
|
|
);
|
|
}
|
|
}
|