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,8 @@
|
||||
namespace TeleWave.Domain.Broadcast.Scheduling;
|
||||
|
||||
/// <summary>Абстракция источника случайности — чтобы планировщик оставался детерминированно тестируемым.</summary>
|
||||
public interface IRandomSource
|
||||
{
|
||||
/// <summary>Случайное целое в диапазоне [0, maxExclusive).</summary>
|
||||
int Next(int maxExclusive);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
namespace TeleWave.Domain.Broadcast.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Чистая эфирная математика: разворачивает конфигурацию канала в последовательность записей встык
|
||||
/// от <see cref="PlannerInput.StartTime"/> до <see cref="PlannerInput.HorizonEnd"/>. Без БД, ФС и
|
||||
/// ffmpeg — полностью юнит-тестируемо (см. SchedulePlannerTests).
|
||||
///
|
||||
/// Инварианты: серии одного шоу идут по порядку (курсор <see cref="PlannerShow.NextEpisodeIndex"/>),
|
||||
/// на конце сериала — заворот на первую серию; выбор шоу — взвешенно-случайный; override на окне
|
||||
/// заменяет базовую ротацию; реклама вставляется по политике канала.
|
||||
/// </summary>
|
||||
public static class SchedulePlanner
|
||||
{
|
||||
private const int IterationBackstop = 1_000_000;
|
||||
|
||||
public static PlannerResult Plan(PlannerInput input, IRandomSource random)
|
||||
{
|
||||
var entries = new List<PlannedEntry>();
|
||||
var byShowId = input.Shows.ToDictionary(s => s.ShowId);
|
||||
var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex);
|
||||
var nextAd = input.NextAdIndex;
|
||||
|
||||
// Есть ли вообще из чего строить эфир.
|
||||
var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0);
|
||||
if (!anyPlayable)
|
||||
return new PlannerResult(entries, nextEpisode, nextAd);
|
||||
|
||||
var cursor = input.StartTime;
|
||||
var iterations = 0;
|
||||
|
||||
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
|
||||
{
|
||||
var candidates = ResolvePolicy(cursor, input, byShowId);
|
||||
if (candidates.Count == 0)
|
||||
break;
|
||||
|
||||
var pick = WeightedPick(candidates, random);
|
||||
var blockStart = cursor;
|
||||
|
||||
var block = CollectBlock(pick, nextEpisode, input, cursor);
|
||||
foreach (var episode in block)
|
||||
{
|
||||
var duration = DurationOf(episode.AssetId, input);
|
||||
var end = cursor + duration;
|
||||
entries.Add(
|
||||
new PlannedEntry(
|
||||
episode.AssetId,
|
||||
ScheduleEntryKind.Program,
|
||||
cursor,
|
||||
end,
|
||||
pick.ShowId,
|
||||
episode.Index
|
||||
)
|
||||
);
|
||||
cursor = end;
|
||||
|
||||
if (input.AdInsertion == AdInsertion.BetweenEpisodes)
|
||||
cursor = InsertAds(entries, input, cursor, ref nextAd);
|
||||
}
|
||||
|
||||
if (input.AdInsertion == AdInsertion.BetweenBlocks)
|
||||
cursor = InsertAds(entries, input, cursor, ref nextAd);
|
||||
|
||||
// Защита от зацикливания, если длительности нулевые/отсутствуют — эфир не сдвинулся.
|
||||
if (cursor <= blockStart)
|
||||
break;
|
||||
}
|
||||
|
||||
return new PlannerResult(entries, nextEpisode, nextAd);
|
||||
}
|
||||
|
||||
private static List<(PlannerShow Show, int Weight)> ResolvePolicy(
|
||||
DateTimeOffset moment,
|
||||
PlannerInput input,
|
||||
IReadOnlyDictionary<Guid, PlannerShow> byShowId
|
||||
)
|
||||
{
|
||||
var ovr = input.Overrides.FirstOrDefault(o => moment >= o.StartsAtUtc && moment < o.EndsAtUtc);
|
||||
if (ovr is not null)
|
||||
{
|
||||
var overridden = new List<(PlannerShow, int)>();
|
||||
foreach (var os in ovr.Shows)
|
||||
{
|
||||
if (!byShowId.TryGetValue(os.ShowId, out var show) || show.EpisodeAssetIds.Count == 0)
|
||||
continue;
|
||||
var weight = ovr.Mode == OverrideMode.Exclusive ? 1 : os.Weight;
|
||||
if (weight > 0)
|
||||
overridden.Add((show, weight));
|
||||
}
|
||||
|
||||
if (overridden.Count > 0)
|
||||
return overridden;
|
||||
// Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации.
|
||||
}
|
||||
|
||||
return input.Shows
|
||||
.Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0)
|
||||
.Select(s => (s, s.Weight))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static PlannerShow WeightedPick(
|
||||
List<(PlannerShow Show, int Weight)> candidates,
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
var total = candidates.Sum(c => c.Weight);
|
||||
if (total <= 0)
|
||||
return candidates[0].Show;
|
||||
|
||||
var roll = random.Next(total);
|
||||
var acc = 0;
|
||||
foreach (var (show, weight) in candidates)
|
||||
{
|
||||
acc += weight;
|
||||
if (roll < acc)
|
||||
return show;
|
||||
}
|
||||
|
||||
return candidates[^1].Show;
|
||||
}
|
||||
|
||||
private static List<(Guid AssetId, int Index)> CollectBlock(
|
||||
PlannerShow show,
|
||||
Dictionary<Guid, int> nextEpisode,
|
||||
PlannerInput input,
|
||||
DateTimeOffset cursor
|
||||
)
|
||||
{
|
||||
var result = new List<(Guid, int)>();
|
||||
var count = show.EpisodeAssetIds.Count;
|
||||
var idx = ((nextEpisode[show.ChannelShowId] % count) + count) % count;
|
||||
|
||||
if (show.BlockMode == BlockMode.Count)
|
||||
{
|
||||
var n = Math.Max(1, show.BlockValue);
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
result.Add((show.EpisodeAssetIds[idx], idx));
|
||||
idx = (idx + 1) % count;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var budget = TimeSpan.FromMinutes(Math.Max(1, show.BlockValue));
|
||||
var accumulated = TimeSpan.Zero;
|
||||
var guard = 0;
|
||||
do
|
||||
{
|
||||
var assetId = show.EpisodeAssetIds[idx];
|
||||
result.Add((assetId, idx));
|
||||
accumulated += DurationOf(assetId, input);
|
||||
idx = (idx + 1) % count;
|
||||
guard++;
|
||||
} while (
|
||||
accumulated < budget
|
||||
&& cursor + accumulated < input.HorizonEnd
|
||||
&& guard < IterationBackstop
|
||||
);
|
||||
}
|
||||
|
||||
nextEpisode[show.ChannelShowId] = idx;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DateTimeOffset InsertAds(
|
||||
List<PlannedEntry> entries,
|
||||
PlannerInput input,
|
||||
DateTimeOffset cursor,
|
||||
ref int nextAd
|
||||
)
|
||||
{
|
||||
if (input.AdPool.Count == 0 || input.AdsPerBreak <= 0)
|
||||
return cursor;
|
||||
|
||||
for (var i = 0; i < input.AdsPerBreak; i++)
|
||||
{
|
||||
var assetId = input.AdPool[((nextAd % input.AdPool.Count) + input.AdPool.Count) % input.AdPool.Count];
|
||||
nextAd++;
|
||||
var end = cursor + DurationOf(assetId, input);
|
||||
entries.Add(new PlannedEntry(assetId, ScheduleEntryKind.Ad, cursor, end, null, null));
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private static TimeSpan DurationOf(Guid assetId, PlannerInput input) =>
|
||||
input.Durations.TryGetValue(assetId, out var duration) ? duration : TimeSpan.Zero;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace TeleWave.Domain.Broadcast.Scheduling;
|
||||
|
||||
/// <summary>Шоу канала, подготовленное для планировщика: только готовые серии, с курсором.</summary>
|
||||
public sealed record PlannerShow(
|
||||
Guid ChannelShowId,
|
||||
Guid ShowId,
|
||||
int Weight,
|
||||
BlockMode BlockMode,
|
||||
int BlockValue,
|
||||
IReadOnlyList<Guid> EpisodeAssetIds,
|
||||
int NextEpisodeIndex
|
||||
);
|
||||
|
||||
/// <summary>Override в терминах планировщика: окно + режим + шоу с весами.</summary>
|
||||
public sealed record PlannerOverride(
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
OverrideMode Mode,
|
||||
IReadOnlyList<PlannerOverrideShow> Shows
|
||||
);
|
||||
|
||||
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
|
||||
|
||||
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
|
||||
public sealed record PlannerInput(
|
||||
Guid ChannelId,
|
||||
AdInsertion AdInsertion,
|
||||
int AdsPerBreak,
|
||||
int NextAdIndex,
|
||||
IReadOnlyList<PlannerShow> Shows,
|
||||
IReadOnlyList<Guid> AdPool,
|
||||
IReadOnlyDictionary<Guid, TimeSpan> Durations,
|
||||
IReadOnlyList<PlannerOverride> Overrides,
|
||||
DateTimeOffset StartTime,
|
||||
DateTimeOffset HorizonEnd
|
||||
);
|
||||
|
||||
/// <summary>Одна запланированная запись (ещё не доменная сущность).</summary>
|
||||
public sealed record PlannedEntry(
|
||||
Guid MediaAssetId,
|
||||
ScheduleEntryKind Kind,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
Guid? ShowId,
|
||||
int? EpisodeIndex
|
||||
);
|
||||
|
||||
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы).</summary>
|
||||
public sealed record PlannerResult(
|
||||
IReadOnlyList<PlannedEntry> Entries,
|
||||
IReadOnlyDictionary<Guid, int> NextEpisodeIndexByChannelShow,
|
||||
int NextAdIndex
|
||||
);
|
||||
Reference in New Issue
Block a user