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,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
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace TeleWave.Domain.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
|
||||
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; курсор показа на канале хранится
|
||||
/// отдельно на связке канал↔шоу (см. Broadcast/ChannelShow).
|
||||
/// </summary>
|
||||
public class Show
|
||||
{
|
||||
private readonly List<ShowEpisode> _episodes = new();
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
public string? Description { get; private set; }
|
||||
public ShowKind Kind { get; private set; }
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
|
||||
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
|
||||
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
|
||||
|
||||
private Show() { }
|
||||
|
||||
public static Show Create(string name, ShowKind kind, string? description = null) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Kind = kind,
|
||||
Description = description,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
public void Rename(string name, string? description)
|
||||
{
|
||||
Name = name;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия.</summary>
|
||||
public ShowEpisode AddEpisode(Guid mediaAssetId)
|
||||
{
|
||||
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
|
||||
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
|
||||
_episodes.Add(episode);
|
||||
return episode;
|
||||
}
|
||||
|
||||
public bool RemoveEpisode(Guid episodeId)
|
||||
{
|
||||
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
|
||||
if (episode is null)
|
||||
return false;
|
||||
_episodes.Remove(episode);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace TeleWave.Domain.Library;
|
||||
|
||||
/// <summary>Одна серия шоу — ссылка на медиа-ассет и позиция в порядке показа.</summary>
|
||||
public class ShowEpisode
|
||||
{
|
||||
public Guid Id { get; private set; }
|
||||
public Guid ShowId { get; private set; }
|
||||
public Guid MediaAssetId { get; private set; }
|
||||
|
||||
/// <summary>Порядковый номер внутри шоу (может иметь разрывы после удалений).</summary>
|
||||
public int Position { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
private ShowEpisode() { }
|
||||
|
||||
internal static ShowEpisode Create(Guid showId, Guid mediaAssetId, int position) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ShowId = showId,
|
||||
MediaAssetId = mediaAssetId,
|
||||
Position = position,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TeleWave.Domain.Library;
|
||||
|
||||
/// <summary>Тип шоу в библиотеке.</summary>
|
||||
public enum ShowKind
|
||||
{
|
||||
/// <summary>Сериал: упорядоченный список серий, идут по порядку.</summary>
|
||||
Series,
|
||||
|
||||
/// <summary>Разовый выпуск/полнометражка: ровно одна «серия».</summary>
|
||||
Single,
|
||||
}
|
||||
Reference in New Issue
Block a user