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:
Leonid Pershin
2026-07-24 08:57:08 +03:00
parent e15ecbdb29
commit 4fa9dae37f
86 changed files with 4094 additions and 15 deletions
@@ -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
);