diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs
index 524f324..be2dcfc 100644
--- a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs
@@ -106,7 +106,8 @@ public static class TemplateEndpoints
templateId,
body.Name,
body.FallbackGroupId,
- body.DefaultJunctionId
+ body.DefaultJunctionId,
+ body.Rules
),
cancellationToken
);
@@ -197,7 +198,8 @@ public static class TemplateEndpoints
public sealed record UpdateTemplateBody(
string Name,
Guid? FallbackGroupId,
- Guid? DefaultJunctionId
+ Guid? DefaultJunctionId,
+ PlanningRules? Rules
);
public sealed record CreateLayerBody(string Name, int Priority);
diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
index beb8d8d..d74823b 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
@@ -258,7 +258,19 @@ public sealed class GridScheduleGenerator(
.Distinct()
.ToList();
- var elementsByGroup = await expander.ExpandAsync(groupIds, channel.Id, cancellationToken);
+ // Правила канала: детское время резолвится на каждый слот по его времени, а под потолок
+ // повторов нужна история показов — её глубина и задаётся окном правила.
+ var rules = PlanningRules.FromJson(template.RulesJson);
+ var repeatLimit = rules?.MaxRepeatsInWindow is { WindowDays: > 0, Max: > 0 } limit
+ ? new RepeatLimit(limit.WindowDays, limit.Max)
+ : null;
+
+ var elementsByGroup = await expander.ExpandAsync(
+ groupIds,
+ channel.Id,
+ cancellationToken,
+ repeatLimit is null ? null : startUtc.AddDays(-repeatLimit.WindowDays)
+ );
var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList();
var states = await dbContext
@@ -308,7 +320,13 @@ public sealed class GridScheduleGenerator(
junctions,
elementsByGroup,
channel
- )
+ ),
+ rules?.AudienceAt(
+ TimeOnly.FromDateTime(
+ item.StartUtc.ToOffset(TimeSpan.FromMinutes(channel.UtcOffsetMinutes)).DateTime
+ )
+ ),
+ repeatLimit
)
);
}
diff --git a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs
index c0befa6..0336386 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs
@@ -1,171 +1,232 @@
-using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Domain.Broadcast;
-using TeleWave.Domain.Media;
-using TeleWave.Domain.Programming;
-using TeleWave.Domain.Programming.Planning;
-
-namespace TeleWave.Application.Programming.Planning;
-
-///
-/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
-/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
-/// фильм — в одну единицу.
-///
-/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
-/// обрабатывается, значит получить дыру в раздаче.
-///
-public sealed class GroupExpander(IAppDbContext dbContext)
-{
- public async Task>> ExpandAsync(
- IReadOnlyCollection groupIds,
- Guid channelId,
- CancellationToken cancellationToken
- )
- {
- var result = new Dictionary>();
- if (groupIds.Count == 0)
- return result;
-
- var groups = await dbContext
- .Groups.AsNoTracking()
- .Include(g => g.Items)
- .Where(g => groupIds.Contains(g.Id))
- .ToListAsync(cancellationToken);
-
- var collectionIds = groups
- .SelectMany(g => g.Items)
- .Where(i => i.ElementKind == GroupElementKind.Collection)
- .Select(i => i.ElementId)
- .Distinct()
- .ToList();
-
- var collectionParts = await dbContext
- .CollectionItems.AsNoTracking()
- .Where(i => collectionIds.Contains(i.CollectionId))
- .OrderBy(i => i.Position)
- .Select(i => new { i.CollectionId, i.ShowId })
- .ToListAsync(cancellationToken);
-
- var showIds = groups
- .SelectMany(g => g.Items)
- .Where(i => i.ElementKind == GroupElementKind.Show)
- .Select(i => i.ElementId)
- .Concat(collectionParts.Select(p => p.ShowId))
- .Distinct()
- .ToList();
-
- var episodes = await dbContext
- .Shows.AsNoTracking()
- .Where(s => showIds.Contains(s.Id))
- .Select(s => new
- {
- s.Id,
- Episodes = s
- .Episodes.OrderBy(e => e.Position)
- .Select(e => new { e.MediaAssetId, e.Position })
- .ToList(),
- })
- .ToDictionaryAsync(s => s.Id, cancellationToken);
-
- var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
- var durations = await dbContext
- .MediaAssets.AsNoTracking()
- .Where(a =>
- assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
- )
- .Select(a => new { a.Id, a.Duration })
- .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
-
- var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
-
- List UnitsOfShow(Guid showId)
- {
- if (!episodes.TryGetValue(showId, out var show))
- return [];
-
- return show
- .Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
- .Select((e, index) => new PlanningUnit(
- e.MediaAssetId,
- durations[e.MediaAssetId],
- showId,
- index
- ))
- .ToList();
- }
-
- foreach (var group in groups)
- {
- var elements = new List();
-
- foreach (var item in group.Items.OrderBy(i => i.Position))
- {
- if (item.ElementKind == GroupElementKind.Show)
- {
- elements.Add(
- new PlanningElement(
- item.ElementKind,
- item.ElementId,
- item.Weight,
- item.Position,
- UnitsOfShow(item.ElementId),
- lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null
- )
- );
- continue;
- }
-
- var partShowIds = collectionParts
- .Where(p => p.CollectionId == item.ElementId)
- .Select(p => p.ShowId)
- .ToList();
-
- var units = partShowIds.SelectMany(UnitsOfShow).ToList();
-
- elements.Add(
- new PlanningElement(
- item.ElementKind,
- item.ElementId,
- item.Weight,
- item.Position,
- units,
- // Остывание коллекции считается по самой свежей из её частей: показ любой
- // из них означает, что франшиза недавно была в эфире.
- partShowIds
- .Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
- .Where(p => p is not null)
- .DefaultIfEmpty(null)
- .Max()
- )
- );
- }
-
- result[group.Id] = elements;
- }
-
- return result;
- }
-
- ///
- /// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
- /// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
- /// остывание среди правил.
- ///
- private async Task> LoadLastPlayedAsync(
- Guid channelId,
- IReadOnlyCollection showIds,
- CancellationToken cancellationToken
- ) =>
- await dbContext
- .ScheduleEntries.AsNoTracking()
- .Where(e =>
- e.ChannelId == channelId
- && e.Kind == ScheduleEntryKind.Program
- && e.ShowId != null
- && showIds.Contains(e.ShowId!.Value)
- )
- .GroupBy(e => e.ShowId!.Value)
- .Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
- .ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
-}
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using TeleWave.Domain.Programming;
+using TeleWave.Domain.Programming.Planning;
+
+namespace TeleWave.Application.Programming.Planning;
+
+///
+/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
+/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
+/// фильм — в одну единицу.
+///
+/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
+/// обрабатывается, значит получить дыру в раздаче.
+///
+public sealed class GroupExpander(IAppDbContext dbContext)
+{
+ /// С какого момента нужна история показов для потолка повторов;
+ /// null — история не нужна и не загружается.
+ public async Task>> ExpandAsync(
+ IReadOnlyCollection groupIds,
+ Guid channelId,
+ CancellationToken cancellationToken,
+ DateTimeOffset? historyFrom = null
+ )
+ {
+ var result = new Dictionary>();
+ if (groupIds.Count == 0)
+ return result;
+
+ var groups = await dbContext
+ .Groups.AsNoTracking()
+ .Include(g => g.Items)
+ .Where(g => groupIds.Contains(g.Id))
+ .ToListAsync(cancellationToken);
+
+ var collectionIds = groups
+ .SelectMany(g => g.Items)
+ .Where(i => i.ElementKind == GroupElementKind.Collection)
+ .Select(i => i.ElementId)
+ .Distinct()
+ .ToList();
+
+ var collectionParts = await dbContext
+ .CollectionItems.AsNoTracking()
+ .Where(i => collectionIds.Contains(i.CollectionId))
+ .OrderBy(i => i.Position)
+ .Select(i => new { i.CollectionId, i.ShowId })
+ .ToListAsync(cancellationToken);
+
+ var showIds = groups
+ .SelectMany(g => g.Items)
+ .Where(i => i.ElementKind == GroupElementKind.Show)
+ .Select(i => i.ElementId)
+ .Concat(collectionParts.Select(p => p.ShowId))
+ .Distinct()
+ .ToList();
+
+ var episodes = await dbContext
+ .Shows.AsNoTracking()
+ .Where(s => showIds.Contains(s.Id))
+ .Select(s => new
+ {
+ s.Id,
+ s.Audience,
+ Episodes = s
+ .Episodes.OrderBy(e => e.Position)
+ .Select(e => new { e.MediaAssetId, e.Position })
+ .ToList(),
+ })
+ .ToDictionaryAsync(s => s.Id, cancellationToken);
+
+ var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
+ var durations = await dbContext
+ .MediaAssets.AsNoTracking()
+ .Where(a =>
+ assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
+ )
+ .Select(a => new { a.Id, a.Duration })
+ .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
+
+ var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
+ var recentPlays = await LoadRecentPlaysAsync(
+ channelId,
+ showIds,
+ historyFrom,
+ cancellationToken
+ );
+
+ List UnitsOfShow(Guid showId)
+ {
+ if (!episodes.TryGetValue(showId, out var show))
+ return [];
+
+ return show
+ .Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
+ .Select((e, index) => new PlanningUnit(
+ e.MediaAssetId,
+ durations[e.MediaAssetId],
+ showId,
+ index
+ ))
+ .ToList();
+ }
+
+ foreach (var group in groups)
+ {
+ var elements = new List();
+
+ foreach (var item in group.Items.OrderBy(i => i.Position))
+ {
+ if (item.ElementKind == GroupElementKind.Show)
+ {
+ elements.Add(
+ new PlanningElement(
+ item.ElementKind,
+ item.ElementId,
+ item.Weight,
+ item.Position,
+ UnitsOfShow(item.ElementId),
+ lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null,
+ episodes.TryGetValue(item.ElementId, out var show) ? show.Audience : null,
+ recentPlays.TryGetValue(item.ElementId, out var plays) ? plays : null
+ )
+ );
+ continue;
+ }
+
+ var partShowIds = collectionParts
+ .Where(p => p.CollectionId == item.ElementId)
+ .Select(p => p.ShowId)
+ .ToList();
+
+ var units = partShowIds.SelectMany(UnitsOfShow).ToList();
+
+ elements.Add(
+ new PlanningElement(
+ item.ElementKind,
+ item.ElementId,
+ item.Weight,
+ item.Position,
+ units,
+ // Остывание коллекции считается по самой свежей из её частей: показ любой
+ // из них означает, что франшиза недавно была в эфире.
+ partShowIds
+ .Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
+ .Where(p => p is not null)
+ .DefaultIfEmpty(null)
+ .Max(),
+ // Категория коллекции — строжайшая среди частей: франшиза идёт целиком,
+ // и одна взрослая часть делает взрослой всю.
+ partShowIds
+ .Select(id =>
+ episodes.TryGetValue(id, out var part) ? part.Audience : (ShowAudience?)null
+ )
+ .Where(a => a is not null)
+ .DefaultIfEmpty(null)
+ .Max(),
+ partShowIds
+ .SelectMany(id =>
+ recentPlays.TryGetValue(id, out var p) ? p : []
+ )
+ .ToList()
+ )
+ );
+ }
+
+ result[group.Id] = elements;
+ }
+
+ return result;
+ }
+
+ ///
+ /// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
+ /// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
+ /// остывание среди правил.
+ ///
+ private async Task> LoadLastPlayedAsync(
+ Guid channelId,
+ IReadOnlyCollection showIds,
+ CancellationToken cancellationToken
+ ) =>
+ await dbContext
+ .ScheduleEntries.AsNoTracking()
+ .Where(e =>
+ e.ChannelId == channelId
+ && e.Kind == ScheduleEntryKind.Program
+ && e.ShowId != null
+ && showIds.Contains(e.ShowId!.Value)
+ )
+ .GroupBy(e => e.ShowId!.Value)
+ .Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
+ .ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
+
+ ///
+ /// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
+ /// это лишние сотни строк на каждый прогон.
+ ///
+ private async Task>> LoadRecentPlaysAsync(
+ Guid channelId,
+ IReadOnlyCollection showIds,
+ DateTimeOffset? from,
+ CancellationToken cancellationToken
+ )
+ {
+ if (from is not { } since || showIds.Count == 0)
+ return [];
+
+ var plays = await dbContext
+ .ScheduleEntries.AsNoTracking()
+ .Where(e =>
+ e.ChannelId == channelId
+ && e.Kind == ScheduleEntryKind.Program
+ && e.ShowId != null
+ && showIds.Contains(e.ShowId!.Value)
+ && e.StartsAtUtc >= since
+ )
+ .Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
+ .ToListAsync(cancellationToken);
+
+ return plays
+ .GroupBy(p => p.ShowId)
+ .ToDictionary(
+ g => g.Key,
+ g => (IReadOnlyList)g.Select(p => p.StartsAtUtc).ToList()
+ );
+ }
+}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs
index a1eeaa7..7ae1b50 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs
@@ -88,6 +88,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
+ PlanningRules.FromJson(template.RulesJson),
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs
index 855d23e..cd59d7d 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs
@@ -92,6 +92,7 @@ public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId);
+ template.SetRules(command.Rules?.ToJson());
template.MarkChanged();
return Result.Success();
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs
index 4c45572..c33d925 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs
@@ -17,12 +17,13 @@ public sealed record UpdateLayerCommand(
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand;
-/// Имя шаблона и аварийная группа, играющая, когда пуст даже фоновый слой.
+/// Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.
public sealed record UpdateTemplateCommand(
Guid TemplateId,
string Name,
Guid? FallbackGroupId,
- Guid? DefaultJunctionId
+ Guid? DefaultJunctionId,
+ PlanningRules? Rules
) : ICommand;
public sealed class CreateLayerCommandValidator : AbstractValidator
@@ -45,5 +46,22 @@ public sealed class UpdateLayerCommandValidator : AbstractValidator
{
- public UpdateTemplateCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
+ public UpdateTemplateCommandValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
+
+ // Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
+ // всё равно ограничена глубиной хранения ленты.
+ RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
+ .InclusiveBetween(1, 365)
+ .When(x => x.Rules?.MaxRepeatsInWindow is not null);
+ RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
+ .InclusiveBetween(1, 1000)
+ .When(x => x.Rules?.MaxRepeatsInWindow is not null);
+
+ RuleForEach(x => x.Rules!.MaxAudienceByTime)
+ .Must(window => window.From != window.To)
+ .WithMessage("Окно нулевой длины ничего не ограничивает.")
+ .When(x => x.Rules?.MaxAudienceByTime is not null);
+ }
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/PlanningRules.cs b/backend/src/TeleWave.Application/Programming/Templates/PlanningRules.cs
new file mode 100644
index 0000000..552f2aa
--- /dev/null
+++ b/backend/src/TeleWave.Application/Programming/Templates/PlanningRules.cs
@@ -0,0 +1,72 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using TeleWave.Domain.Library;
+
+namespace TeleWave.Application.Programming.Templates;
+
+///
+/// Окно детского времени: с до во времени канала
+/// в эфир идёт только контент не строже . Окно может переходить
+/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
+///
+public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
+{
+ public bool Contains(TimeOnly moment) =>
+ From <= To ? moment >= From && moment < To : moment >= From || moment < To;
+}
+
+///
+/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
+/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
+///
+/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
+/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
+///
+public sealed record PlanningRules(
+ IReadOnlyList? MaxAudienceByTime = null,
+ /// Не чаще Max раз за WindowDays суток (null — без ограничения).
+ RepeatLimitRule? MaxRepeatsInWindow = null
+)
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ /// Возрастной потолок в указанный момент времени канала (null — без ограничения).
+ public ShowAudience? AudienceAt(TimeOnly moment)
+ {
+ if (MaxAudienceByTime is not { Count: > 0 } windows)
+ return null;
+
+ // Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
+ // отменяться более широким окном, случайно наложенным сверху.
+ ShowAudience? strictest = null;
+ foreach (var window in windows.Where(w => w.Contains(moment)))
+ strictest = strictest is { } current && current <= window.MaxAudience
+ ? current
+ : window.MaxAudience;
+ return strictest;
+ }
+
+ public string ToJson() => JsonSerializer.Serialize(this, Options);
+
+ public static PlanningRules? FromJson(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ try
+ {
+ return JsonSerializer.Deserialize(json, Options);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
+
+public sealed record RepeatLimitRule(int WindowDays, int Max);
diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs
index edb0488..7902874 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs
@@ -41,6 +41,8 @@ public sealed record ScheduleTemplateDto(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
+ /// Правила отбора кандидатов канала: детское время и потолок повторов.
+ PlanningRules? Rules,
int Revision,
int AppliedRevision,
/// Есть ли правки правил, ещё не применённые к эфиру.
diff --git a/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs b/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs
index 586d0fc..1118216 100644
--- a/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs
+++ b/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs
@@ -22,7 +22,11 @@ public static class ElementSelector
IRandomSource random
)
{
- var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList();
+ // Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора:
+ // начатый в общее время сериал не должен доигрываться в детское.
+ var playable = slot
+ .Elements.Where(e => e.Units.Count > 0 && IsAllowedByAudience(slot, e))
+ .ToList();
if (playable.Count == 0)
return null;
@@ -95,10 +99,14 @@ public static class ElementSelector
if (current is not null && HasUnitsLeft(slot, current))
return Continue(slot, current);
+ // Потолок повторов — тоже жёсткий фильтр, но применяется только здесь: в последовательной
+ // стратегии он выбрасывал бы очередную серию сериала и рвал порядок показа.
+ var withinLimit = ApplyRepeatLimit(slot, playable, moment);
+
var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
var eligible = cooldown <= TimeSpan.Zero
- ? playable
- : playable
+ ? withinLimit
+ : withinLimit
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
.ToList();
@@ -106,16 +114,47 @@ public static class ElementSelector
if (exhausted)
{
// Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
- // пустой эфир хуже раннего повтора в обоих случаях.
+ // пустой эфир хуже раннего повтора в обоих случаях. Откат идёт по уже суженному потолком
+ // повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим.
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
- ? playable
- : [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
+ ? withinLimit
+ : [withinLimit.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
}
var picked = WeightedPick(eligible, random);
return new ElementPick(picked, 0, eligible.Count, exhausted);
}
+ ///
+ /// Проходит ли элемент по возрастному потолку слота. Категории упорядочены по строгости, поэтому
+ /// сравнение — обычное «не строже». Элемент без категории не отсекается: неизвестное не значит
+ /// «взрослое», а молчаливое выбрасывание такого контента искало бы потом весь вечер.
+ ///
+ public static bool IsAllowedByAudience(PlanningSlot slot, PlanningElement element) =>
+ slot.MaxAudience is not { } max || element.Audience is not { } audience || audience <= max;
+
+ ///
+ /// Отсекает элементы, выбранные за период чаще потолка. История — старты уже записанных показов;
+ /// без неё (правило не задано или лента не загружена) фильтр не работает и никого не выбрасывает.
+ ///
+ private static List ApplyRepeatLimit(
+ PlanningSlot slot,
+ List playable,
+ DateTimeOffset moment
+ )
+ {
+ if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0)
+ return playable;
+
+ var from = moment.AddDays(-limit.WindowDays);
+ var filtered = playable
+ .Where(e => (e.RecentPlaysUtc?.Count(p => p >= from) ?? 0) < limit.Max)
+ .ToList();
+
+ // Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание.
+ return filtered.Count == 0 ? playable : filtered;
+ }
+
/// Продолжение текущего элемента с позиции курсора.
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
{
diff --git a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
index 4c600b9..bc638d0 100644
--- a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
+++ b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs
@@ -1,3 +1,5 @@
+using TeleWave.Domain.Library;
+
namespace TeleWave.Domain.Programming.Planning;
///
@@ -16,9 +18,16 @@ public sealed record PlanningElement(
int Weight,
int Position,
IReadOnlyList Units,
- DateTimeOffset? LastPlayedUtc = null
+ DateTimeOffset? LastPlayedUtc = null,
+ /// Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.
+ ShowAudience? Audience = null,
+ /// Старты недавних показов в этом канале — по ним считается потолок повторов за период.
+ IReadOnlyList? RecentPlaysUtc = null
);
+/// Потолок повторов: не чаще раз за суток.
+public sealed record RepeatLimit(int WindowDays, int Max);
+
/// Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.
public sealed record PlanningStrategy(
SlotStrategyKind Kind,
@@ -66,7 +75,11 @@ public sealed record PlanningSlot(
/// Врезки между единицами внутри блока.
PlanningJunction? JunctionBetween = null,
/// Врезки в конце блока.
- PlanningJunction? JunctionAfter = null
+ PlanningJunction? JunctionAfter = null,
+ /// Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.
+ ShowAudience? MaxAudience = null,
+ /// Потолок повторов за период (null — без ограничения). Жёсткий фильтр.
+ RepeatLimit? RepeatLimit = null
)
{
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
@@ -189,4 +202,7 @@ public enum PlanningWarningKind
/// Пусто даже в фоне — в ленте образуется дыра.
FallbackEmpty = 4,
+
+ /// Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.
+ CandidatesFiltered = 5,
}
diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs
index a897447..5a89429 100644
--- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs
+++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs
@@ -244,12 +244,26 @@ public static class SchedulePlanner
var pick = ElementSelector.Select(slot, cursor, random);
if (pick is null)
{
+ // Пустая группа и отсечённая фильтром — разные беды: во втором случае контент есть,
+ // но не подходит по правилам, и админу надо чинить правило, а не состав группы.
+ var filteredOut =
+ slot.Elements.Any(e => e.Units.Count > 0)
+ && !slot.Elements.Any(e =>
+ e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
+ );
+
warnings.Add(
- new PlanningWarning(
- PlanningWarningKind.SlotEmpty,
- slot.SlotId,
- "Слот не дал контента — место закрыл фон."
- )
+ filteredOut
+ ? new PlanningWarning(
+ PlanningWarningKind.CandidatesFiltered,
+ slot.SlotId,
+ "Возрастной потолок отсёк всех кандидатов — место закрыл фон."
+ )
+ : new PlanningWarning(
+ PlanningWarningKind.SlotEmpty,
+ slot.SlotId,
+ "Слот не дал контента — место закрыл фон."
+ )
);
return FillWithFallback(
cursor,
diff --git a/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs b/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs
index 7cc3af9..535db57 100644
--- a/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs
+++ b/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs
@@ -22,6 +22,12 @@ public class ScheduleTemplate
/// Стык по умолчанию — для слотов, у которых свой не задан.
public Guid? DefaultJunctionId { get; private set; }
+ ///
+ /// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
+ /// схема живёт в Application, как и у стратегий слотов и условий стыка.
+ ///
+ public string? RulesJson { get; private set; }
+
/// Номер правки правил; входит в кэш-ключи и историю.
public int Revision { get; private set; }
@@ -63,6 +69,9 @@ public class ScheduleTemplate
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
+ public void SetRules(string? rulesJson) =>
+ RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
+
/// Отметить, что правила изменились — эфир пойдёт по старым до применения.
public void MarkChanged() => Revision++;
diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726110935_TemplatePlanningRules.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726110935_TemplatePlanningRules.Designer.cs
new file mode 100644
index 0000000..c171982
--- /dev/null
+++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726110935_TemplatePlanningRules.Designer.cs
@@ -0,0 +1,1371 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TeleWave.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace TeleWave.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260726110935_TemplatePlanningRules")]
+ partial class TemplatePlanningRules
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReplacedByTokenHash")
+ .HasColumnType("text");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FromShowId")
+ .HasColumnType("uuid");
+
+ b.Property("MediaAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("Signature")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("ToShowId")
+ .HasColumnType("uuid");
+
+ b.Property("VariantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FromShowId", "ToShowId", "Signature");
+
+ b.ToTable("BumperAssets");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AccentColor")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("AudioDurationSeconds")
+ .HasColumnType("double precision");
+
+ b.Property("AudioExtension")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("BackgroundColor")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("BackgroundColor2")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("BackgroundImageId")
+ .HasColumnType("uuid");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Revision")
+ .HasColumnType("integer");
+
+ b.Property("TextColor")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId", "Position");
+
+ b.ToTable("BumperTemplate");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BumperTemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("Line1")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Line2")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("NextLabel")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("NowLabel")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Trigger")
+ .HasColumnType("integer");
+
+ b.Property("Weight")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.HasKey("Id");
+
+ b.HasIndex("BumperTemplateId", "Position");
+
+ b.ToTable("BumperTextVariants");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BumperFont")
+ .HasColumnType("integer");
+
+ b.Property("BumperSelection")
+ .HasColumnType("integer");
+
+ b.Property("BumpersEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DayStartTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("EpochUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FillerAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Number")
+ .HasColumnType("integer");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("UtcOffsetMinutes")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Number")
+ .IsUnique()
+ .HasFilter("\"Number\" IS NOT NULL");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BumperVariantId")
+ .HasColumnType("uuid");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("EndsAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EpisodeIndex")
+ .HasColumnType("integer");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("MediaAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.Property("SlotId")
+ .HasColumnType("uuid");
+
+ b.Property("StartsAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TraceJson")
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId", "EndsAtUtc");
+
+ b.HasIndex("ChannelId", "StartsAtUtc");
+
+ b.HasIndex("ChannelId", "ShowId", "StartsAtUtc");
+
+ b.ToTable("ScheduleEntries");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Images.Image", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Category")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileExtension")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Category", "CreatedAt");
+
+ b.ToTable("Images");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.Collection", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PosterImageId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("Collections");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CollectionId")
+ .HasColumnType("uuid");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ShowId");
+
+ b.HasIndex("CollectionId", "Position");
+
+ b.HasIndex("CollectionId", "ShowId")
+ .IsUnique();
+
+ b.ToTable("CollectionItems");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.Genre", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsSystem")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Genres");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("GenreId")
+ .HasColumnType("uuid");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GenreId");
+
+ b.HasIndex("Value")
+ .IsUnique();
+
+ b.ToTable("GenreAliases");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Audience")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("MetadataExternalId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("MetadataProvider")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("OriginalName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PosterImageId")
+ .HasColumnType("uuid");
+
+ b.Property("Year")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Shows");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AirDate")
+ .HasColumnType("date");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Episode")
+ .HasColumnType("integer");
+
+ b.Property("MediaAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("Overview")
+ .HasMaxLength(4096)
+ .HasColumnType("character varying(4096)");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Season")
+ .HasColumnType("integer");
+
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.Property("StillImageId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaAssetId");
+
+ b.HasIndex("ShowId", "Position");
+
+ b.ToTable("ShowEpisode");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b =>
+ {
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.Property("GenreId")
+ .HasColumnType("uuid");
+
+ b.Property("IsPrimary")
+ .HasColumnType("boolean");
+
+ b.HasKey("ShowId", "GenreId");
+
+ b.HasIndex("GenreId");
+
+ b.ToTable("ShowGenres");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AudioCodec")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Duration")
+ .HasColumnType("interval");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Height")
+ .HasColumnType("integer");
+
+ b.Property("OriginalExtension")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("OriginalFileName")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("ProcessingDuration")
+ .HasColumnType("interval");
+
+ b.Property("ProcessingStartedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RelativePath")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("SegmentCount")
+ .HasColumnType("integer");
+
+ b.Property("SegmentSeconds")
+ .HasColumnType("integer");
+
+ b.Property("Source")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VideoCodec")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Width")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("Status");
+
+ b.ToTable("MediaAssets");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ApplicabilityJson")
+ .HasColumnType("jsonb");
+
+ b.Property("IsBackground")
+ .HasColumnType("boolean");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Priority")
+ .HasColumnType("integer");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TemplateId", "Priority");
+
+ b.ToTable("GridLayers");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.Group", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("FilterJson")
+ .HasColumnType("jsonb");
+
+ b.Property("ItemCount")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("StatsComputedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TotalDuration")
+ .HasColumnType("interval");
+
+ b.Property("UnitCount")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Groups");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ElementId")
+ .HasColumnType("uuid");
+
+ b.Property("ElementKind")
+ .HasColumnType("integer");
+
+ b.Property("GroupId")
+ .HasColumnType("uuid");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Weight")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ElementKind", "ElementId");
+
+ b.HasIndex("GroupId", "Position");
+
+ b.HasIndex("GroupId", "ElementKind", "ElementId")
+ .IsUnique();
+
+ b.ToTable("GroupItems");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AmountMode")
+ .HasColumnType("integer");
+
+ b.Property("AmountValue")
+ .HasColumnType("integer");
+
+ b.Property("BumperTemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("ConditionsJson")
+ .HasColumnType("jsonb");
+
+ b.Property("GroupId")
+ .HasColumnType("uuid");
+
+ b.Property("IsRequired")
+ .HasColumnType("boolean");
+
+ b.Property("JunctionTemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroupId");
+
+ b.HasIndex("JunctionTemplateId", "Position");
+
+ b.ToTable("JunctionElements");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId");
+
+ b.ToTable("JunctionTemplates");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AppliedRevision")
+ .HasColumnType("integer");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DefaultJunctionId")
+ .HasColumnType("uuid");
+
+ b.Property("FallbackGroupId")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Revision")
+ .HasColumnType("integer");
+
+ b.Property("RulesJson")
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId");
+
+ b.ToTable("ScheduleTemplates");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BlockMode")
+ .HasColumnType("integer");
+
+ b.Property("BlockValue")
+ .HasColumnType("integer");
+
+ b.Property("Daypart")
+ .HasColumnType("integer");
+
+ b.Property("GroupId")
+ .HasColumnType("uuid");
+
+ b.Property("IsAnchor")
+ .HasColumnType("boolean");
+
+ b.Property("JunctionAfterId")
+ .HasColumnType("uuid");
+
+ b.Property("JunctionBetweenId")
+ .HasColumnType("uuid");
+
+ b.Property("LayerId")
+ .HasColumnType("uuid");
+
+ b.Property("MaxDriftMinutes")
+ .HasColumnType("integer");
+
+ b.Property("OverflowPolicy")
+ .HasColumnType("integer");
+
+ b.Property("RepeatSourceJson")
+ .HasColumnType("jsonb");
+
+ b.Property("SlotKind")
+ .HasColumnType("integer");
+
+ b.Property("SnapToMinutes")
+ .HasColumnType("integer");
+
+ b.Property("StrategyJson")
+ .HasColumnType("jsonb");
+
+ b.Property("TargetDurationMinutes")
+ .HasColumnType("integer");
+
+ b.Property("TargetStart")
+ .HasColumnType("time without time zone");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Weekday")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroupId");
+
+ b.HasIndex("LayerId", "TargetStart");
+
+ b.ToTable("Slots");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b =>
+ {
+ b.Property("SlotId")
+ .HasColumnType("uuid");
+
+ b.Property("CurrentElementId")
+ .HasColumnType("uuid");
+
+ b.Property("CurrentElementKind")
+ .HasColumnType("integer");
+
+ b.Property("NextUnitIndex")
+ .HasColumnType("integer");
+
+ b.HasKey("SlotId");
+
+ b.ToTable("SlotStates");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property