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,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Политика вставки рекламы на канале.</summary>
public enum AdInsertion
{
/// <summary>Реклама после целого блока серий.</summary>
BetweenBlocks,
/// <summary>Реклама после каждой серии.</summary>
BetweenEpisodes,
}
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как измеряется блок серий одного шоу за один выбор ротации.</summary>
public enum BlockMode
{
/// <summary>Ровно N серий подряд.</summary>
Count,
/// <summary>Набор серий подряд, пока не наберётся ~M минут (последняя входит целиком).</summary>
Duration,
}
@@ -0,0 +1,131 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Канал линейного эфира: базовая взвешенная ротация шоу (<see cref="Shows"/>), пул рекламы
/// (<see cref="Ads"/>), временные override'ы (<see cref="Overrides"/>) и политика вставки рекламы.
/// Планировщик разворачивает всё это в расписание встык на несколько дней вперёд.
/// </summary>
public class Channel
{
private readonly List<ChannelShow> _shows = new();
private readonly List<ChannelAd> _ads = new();
private readonly List<ProgrammingOverride> _overrides = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Slug { get; private set; } = string.Empty;
public bool IsEnabled { get; private set; }
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { get; private set; }
public AdInsertion AdInsertion { get; private set; }
public int AdsPerBreak { get; private set; }
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
/// <summary>Курсор ротации рекламного пула.</summary>
public int NextAdIndex { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyList<ChannelShow> Shows => _shows;
/// <summary>Пул рекламы (backing-field для EF); порядок ротации — по <see cref="ChannelAd.Position"/>.</summary>
public IReadOnlyList<ChannelAd> Ads => _ads;
public IReadOnlyList<ProgrammingOverride> Overrides => _overrides;
private Channel() { }
public static Channel Create(string name, string slug, DateTimeOffset epochUtc) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug,
IsEnabled = true,
EpochUtc = epochUtc,
AdInsertion = AdInsertion.BetweenBlocks,
AdsPerBreak = 1,
NextAdIndex = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
public void UpdateSettings(
string name,
bool isEnabled,
AdInsertion adInsertion,
int adsPerBreak,
Guid? fillerAssetId
)
{
Name = name;
IsEnabled = isEnabled;
AdInsertion = adInsertion;
AdsPerBreak = adsPerBreak;
FillerAssetId = fillerAssetId;
}
public ChannelShow? FindShow(Guid channelShowId) => _shows.FirstOrDefault(s => s.Id == channelShowId);
public ChannelShow AddShow(Guid showId, int weight, BlockMode blockMode, int blockValue)
{
var channelShow = ChannelShow.Create(Id, showId, weight, blockMode, blockValue);
_shows.Add(channelShow);
return channelShow;
}
public bool RemoveShow(Guid channelShowId)
{
var channelShow = _shows.FirstOrDefault(s => s.Id == channelShowId);
if (channelShow is null)
return false;
_shows.Remove(channelShow);
return true;
}
public bool HasShow(Guid showId) => _shows.Any(s => s.ShowId == showId);
public ChannelAd AddAd(Guid mediaAssetId)
{
var nextPosition = _ads.Count == 0 ? 0 : _ads.Max(a => a.Position) + 1;
var ad = ChannelAd.Create(Id, mediaAssetId, nextPosition);
_ads.Add(ad);
return ad;
}
public bool RemoveAd(Guid channelAdId)
{
var ad = _ads.FirstOrDefault(a => a.Id == channelAdId);
if (ad is null)
return false;
_ads.Remove(ad);
return true;
}
public bool HasAd(Guid mediaAssetId) => _ads.Any(a => a.MediaAssetId == mediaAssetId);
public ProgrammingOverride AddOverride(
OverrideMode mode,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
)
{
var ovr = ProgrammingOverride.Create(Id, mode, startsAtUtc, endsAtUtc);
_overrides.Add(ovr);
return ovr;
}
public bool RemoveOverride(Guid overrideId)
{
var ovr = _overrides.FirstOrDefault(o => o.Id == overrideId);
if (ovr is null)
return false;
_overrides.Remove(ovr);
return true;
}
/// <summary>Планировщик двигает курсор рекламы по мере вставки врезок.</summary>
public void SetNextAdIndex(int index) => NextAdIndex = index;
}
@@ -0,0 +1,21 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Рекламный ассет в пуле канала. Врезки крутятся по кругу в порядке <see cref="Position"/>.</summary>
public class ChannelAd
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public int Position { get; private set; }
private ChannelAd() { }
internal static ChannelAd Create(Guid channelId, Guid mediaAssetId, int position) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Position = position,
};
}
@@ -0,0 +1,55 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Связка канал↔шоу: вес в случайной ротации, режим и размер блока, а также персональный для этого
/// канала курсор серий (<see cref="NextEpisodeIndex"/>) — индекс следующей серии в упорядоченном
/// списке шоу.
/// </summary>
public class ChannelShow
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid ShowId { get; private set; }
public int Weight { get; private set; }
public BlockMode BlockMode { get; private set; }
/// <summary>Число серий (<see cref="BlockMode.Count"/>) или минут (<see cref="BlockMode.Duration"/>).</summary>
public int BlockValue { get; private set; }
public bool IsEnabled { get; private set; }
/// <summary>Индекс следующей серии для этого канала (0-based в упорядоченном списке серий шоу).</summary>
public int NextEpisodeIndex { get; private set; }
private ChannelShow() { }
internal static ChannelShow Create(
Guid channelId,
Guid showId,
int weight,
BlockMode blockMode,
int blockValue
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
ShowId = showId,
Weight = weight,
BlockMode = blockMode,
BlockValue = blockValue,
IsEnabled = true,
NextEpisodeIndex = 0,
};
public void Update(int weight, BlockMode blockMode, int blockValue, bool isEnabled)
{
Weight = weight;
BlockMode = blockMode;
BlockValue = blockValue;
IsEnabled = isEnabled;
}
/// <summary>Планировщик двигает курсор по мере постановки серий в расписание.</summary>
public void SetNextEpisodeIndex(int index) => NextEpisodeIndex = index;
}
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Режим временного override (марафон / кампания) поверх базовой ротации.</summary>
public enum OverrideMode
{
/// <summary>В окне играет только одно шоу (марафон).</summary>
Exclusive,
/// <summary>В окне действуют подменённые веса перечисленных шоу (остальные не участвуют).</summary>
Boost,
}
@@ -0,0 +1,21 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Шоу внутри override с его подменённым весом (для Boost) или единственное шоу (для Exclusive).</summary>
public class OverrideShow
{
public Guid Id { get; private set; }
public Guid ProgrammingOverrideId { get; private set; }
public Guid ShowId { get; private set; }
public int Weight { get; private set; }
private OverrideShow() { }
internal static OverrideShow Create(Guid overrideId, Guid showId, int weight) =>
new()
{
Id = Guid.NewGuid(),
ProgrammingOverrideId = overrideId,
ShowId = showId,
Weight = weight,
};
}
@@ -0,0 +1,45 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Временный override программирования канала на окне [<see cref="StartsAtUtc"/>,
/// <see cref="EndsAtUtc"/>). Марафон = <see cref="OverrideMode.Exclusive"/> с одним шоу и большим
/// временным блоком. Пересекающийся с генерируемым временем override заменяет базовую ротацию.
/// </summary>
public class ProgrammingOverride
{
private readonly List<OverrideShow> _shows = new();
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public OverrideMode Mode { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
public IReadOnlyList<OverrideShow> Shows => _shows;
private ProgrammingOverride() { }
internal static ProgrammingOverride Create(
Guid channelId,
OverrideMode mode,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Mode = mode,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
public OverrideShow AddShow(Guid showId, int weight)
{
var entry = OverrideShow.Create(Id, showId, weight);
_shows.Add(entry);
return entry;
}
public bool Covers(DateTimeOffset moment) => moment >= StartsAtUtc && moment < EndsAtUtc;
}
@@ -0,0 +1,59 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary>
public class ScheduleEntry
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
private ScheduleEntry() { }
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid showId,
int episodeIndex
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
};
public static ScheduleEntry Ad(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
}
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Тип записи расписания.</summary>
public enum ScheduleEntryKind
{
/// <summary>Программа (серия шоу).</summary>
Program,
/// <summary>Рекламная врезка.</summary>
Ad,
}
@@ -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
);