Normalize line endings to LF via .gitattributes

Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка
работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево,
чтобы форматтеры не переписывали файлы целиком на каждом прогоне.

Коммит чисто механический: изменений содержимого нет, только концы строк.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:37:33 +03:00
co-authored by Claude Opus 5
parent 9d1c6d2fc3
commit 0442056367
109 changed files with 13469 additions and 13459 deletions
File diff suppressed because it is too large Load Diff
@@ -1,237 +1,237 @@
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;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param>
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken,
DateTimeOffset? historyFrom = null
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
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 loaded = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Audience,
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
})
.ToListAsync(cancellationToken);
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
var assetIds = shows.Values.SelectMany(s => s.AssetIds).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
);
var data = new ExpansionData(
shows,
durations,
lastPlayed,
recentPlays,
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
);
foreach (var group in groups)
result[group.Id] = group
.Items.OrderBy(i => i.Position)
.Select(item =>
item.ElementKind == GroupElementKind.Show
? data.ShowElement(item)
: data.CollectionElement(item)
)
.ToList();
return result;
}
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
/// <summary>
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
/// В БД отсюда не ходят — всё уже загружено.
/// </summary>
private sealed record ExpansionData(
IReadOnlyDictionary<Guid, ShowRow> Shows,
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
ILookup<Guid, Guid> CollectionParts
)
{
public PlanningElement ShowElement(GroupItem item) =>
new(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOf(item.ElementId),
LastPlayedOf(item.ElementId),
AudienceOf(item.ElementId),
RecentPlaysOf(item.ElementId)
);
/// <summary>
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
/// </summary>
public PlanningElement CollectionElement(GroupItem item)
{
var parts = CollectionParts[item.ElementId].ToList();
return new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
parts.SelectMany(UnitsOf).ToList(),
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
);
}
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
private List<PlanningUnit> UnitsOf(Guid showId) =>
Shows.TryGetValue(showId, out var show)
? show
.AssetIds.Where(Durations.ContainsKey)
.Select(
(assetId, index) =>
new PlanningUnit(assetId, Durations[assetId], showId, index)
)
.ToList()
: [];
private DateTimeOffset? LastPlayedOf(Guid showId) =>
LastPlayed.TryGetValue(showId, out var at) ? at : null;
private ShowAudience? AudienceOf(Guid showId) =>
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> 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);
/// <summary>
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
/// это лишние сотни строк на каждый прогон.
/// </summary>
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
Guid channelId,
IReadOnlyCollection<Guid> 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<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
);
}
}
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;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param>
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken,
DateTimeOffset? historyFrom = null
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
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 loaded = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Audience,
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
})
.ToListAsync(cancellationToken);
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
var assetIds = shows.Values.SelectMany(s => s.AssetIds).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
);
var data = new ExpansionData(
shows,
durations,
lastPlayed,
recentPlays,
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
);
foreach (var group in groups)
result[group.Id] = group
.Items.OrderBy(i => i.Position)
.Select(item =>
item.ElementKind == GroupElementKind.Show
? data.ShowElement(item)
: data.CollectionElement(item)
)
.ToList();
return result;
}
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
/// <summary>
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
/// В БД отсюда не ходят — всё уже загружено.
/// </summary>
private sealed record ExpansionData(
IReadOnlyDictionary<Guid, ShowRow> Shows,
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
ILookup<Guid, Guid> CollectionParts
)
{
public PlanningElement ShowElement(GroupItem item) =>
new(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOf(item.ElementId),
LastPlayedOf(item.ElementId),
AudienceOf(item.ElementId),
RecentPlaysOf(item.ElementId)
);
/// <summary>
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
/// </summary>
public PlanningElement CollectionElement(GroupItem item)
{
var parts = CollectionParts[item.ElementId].ToList();
return new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
parts.SelectMany(UnitsOf).ToList(),
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
);
}
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
private List<PlanningUnit> UnitsOf(Guid showId) =>
Shows.TryGetValue(showId, out var show)
? show
.AssetIds.Where(Durations.ContainsKey)
.Select(
(assetId, index) =>
new PlanningUnit(assetId, Durations[assetId], showId, index)
)
.ToList()
: [];
private DateTimeOffset? LastPlayedOf(Guid showId) =>
LastPlayed.TryGetValue(showId, out var at) ? at : null;
private ShowAudience? AudienceOf(Guid showId) =>
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> 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);
/// <summary>
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
/// это лишние сотни строк на каждый прогон.
/// </summary>
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
Guid channelId,
IReadOnlyCollection<Guid> 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<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
);
}
}
@@ -1,39 +1,39 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
@@ -1,152 +1,152 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName = entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var collectionName = entry.CollectionId is { } collectionId
? await dbContext
.Collections.AsNoTracking()
.Where(c => c.Id == collectionId)
.Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
collectionName,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext
.Slots.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
collectionName,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName = entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var collectionName = entry.CollectionId is { } collectionId
? await dbContext
.Collections.AsNoTracking()
.Where(c => c.Id == collectionId)
.Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
collectionName,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext
.Slots.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
collectionName,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
@@ -1,231 +1,231 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
{
public async Task<Result<CopyTemplateResultDto>> Handle(
CopyTemplateCommand command,
CancellationToken cancellationToken
)
{
var target = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
var source = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var (junctionMap, droppedBumperRefs) = CopyJunctions(
sourceJunctions,
target.Id,
sourceBumperNames,
bumperByName
);
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext
.JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
)
.ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
if (
source.DefaultJunctionId is { } defaultJunction
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
)
copyTemplate.SetDefaultJunction(mappedDefault);
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
);
}
/// <summary>
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
/// </summary>
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
IReadOnlyList<JunctionTemplate> sourceJunctions,
Guid targetChannelId,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName
)
{
var map = new Dictionary<Guid, Guid>();
var dropped = 0;
foreach (var junction in sourceJunctions)
{
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
map[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position))
{
var bumperTemplateId = MapBumper(
element,
sourceBumperNames,
targetBumperByName,
ref dropped
);
copy.AddElement(element.Kind)
.Update(
element.Kind,
element.GroupId,
bumperTemplateId,
element.AmountMode,
element.AmountValue,
element.IsRequired,
element.ConditionsJson
);
}
dbContext.JunctionTemplates.Add(copy);
}
return (map, dropped);
}
/// <summary>
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
/// и это попадает в отчёт.
/// </summary>
private static Guid? MapBumper(
JunctionElement element,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName,
ref int dropped
)
{
if (element.Kind != JunctionElementKind.Bumper)
return null;
if (
element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name)
&& targetBumperByName.TryGetValue(name, out var mapped)
)
return mapped;
dropped++;
return null;
}
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source,
ScheduleTemplate copyTemplate,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var layers = 0;
var slots = 0;
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
{
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
var copyLayer = layer.IsBackground
? copyTemplate.Background!
: copyTemplate.AddLayer(layer.Name, layer.Priority);
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
if (!layer.IsBackground)
layers++;
foreach (var slot in layer.Slots)
{
CopySlot(slot, copyLayer, junctionMap);
slots++;
}
}
return (layers, slots);
}
private static void CopySlot(
Slot slot,
GridLayer copyLayer,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.SlotKind,
slot.Weekday
);
copySlot.UpdateTiming(
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes
);
copySlot.UpdateContent(
new SlotContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
)
);
}
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
{
public async Task<Result<CopyTemplateResultDto>> Handle(
CopyTemplateCommand command,
CancellationToken cancellationToken
)
{
var target = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
var source = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var (junctionMap, droppedBumperRefs) = CopyJunctions(
sourceJunctions,
target.Id,
sourceBumperNames,
bumperByName
);
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext
.JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
)
.ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
if (
source.DefaultJunctionId is { } defaultJunction
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
)
copyTemplate.SetDefaultJunction(mappedDefault);
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
);
}
/// <summary>
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
/// </summary>
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
IReadOnlyList<JunctionTemplate> sourceJunctions,
Guid targetChannelId,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName
)
{
var map = new Dictionary<Guid, Guid>();
var dropped = 0;
foreach (var junction in sourceJunctions)
{
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
map[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position))
{
var bumperTemplateId = MapBumper(
element,
sourceBumperNames,
targetBumperByName,
ref dropped
);
copy.AddElement(element.Kind)
.Update(
element.Kind,
element.GroupId,
bumperTemplateId,
element.AmountMode,
element.AmountValue,
element.IsRequired,
element.ConditionsJson
);
}
dbContext.JunctionTemplates.Add(copy);
}
return (map, dropped);
}
/// <summary>
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
/// и это попадает в отчёт.
/// </summary>
private static Guid? MapBumper(
JunctionElement element,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName,
ref int dropped
)
{
if (element.Kind != JunctionElementKind.Bumper)
return null;
if (
element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name)
&& targetBumperByName.TryGetValue(name, out var mapped)
)
return mapped;
dropped++;
return null;
}
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source,
ScheduleTemplate copyTemplate,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var layers = 0;
var slots = 0;
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
{
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
var copyLayer = layer.IsBackground
? copyTemplate.Background!
: copyTemplate.AddLayer(layer.Name, layer.Priority);
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
if (!layer.IsBackground)
layers++;
foreach (var slot in layer.Slots)
{
CopySlot(slot, copyLayer, junctionMap);
slots++;
}
}
return (layers, slots);
}
private static void CopySlot(
Slot slot,
GridLayer copyLayer,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.SlotKind,
slot.Weekday
);
copySlot.UpdateTiming(
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes
);
copySlot.UpdateContent(
new SlotContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
)
);
}
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
@@ -1,102 +1,102 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.GetTemplate;
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
{
public async Task<Result<ScheduleTemplateDto>> Handle(
GetChannelTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
var groupIds = template
.Layers.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var layers = template
.Layers.OrderByDescending(l => l.Priority)
.Select(layer => new GridLayerDto(
layer.Id,
layer.Name,
layer.Priority,
layer.IsEnabled,
layer.IsBackground,
LayerApplicability.FromJson(layer.ApplicabilityJson),
layer
.Slots.OrderBy(s => s.Weekday ?? -1)
.ThenBy(s => s.TargetStart)
.Select(slot => new SlotDto(
slot.Id,
slot.LayerId,
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Title,
slot.Daypart,
slot.SlotKind,
slot.GroupId,
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
SlotStrategy.FromJson(slot.StrategyJson),
RepeatSource.FromJson(slot.RepeatSourceJson),
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetweenId,
slot.JunctionAfterId
))
.ToList()
))
.ToList();
return Result.Success(
new ScheduleTemplateDto(
template.Id,
template.ChannelId,
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
PlanningRules.FromJson(template.RulesJson),
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
channel.UtcOffsetMinutes,
channel.DayStartTime,
layers
)
);
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.GetTemplate;
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
{
public async Task<Result<ScheduleTemplateDto>> Handle(
GetChannelTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
var groupIds = template
.Layers.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var layers = template
.Layers.OrderByDescending(l => l.Priority)
.Select(layer => new GridLayerDto(
layer.Id,
layer.Name,
layer.Priority,
layer.IsEnabled,
layer.IsBackground,
LayerApplicability.FromJson(layer.ApplicabilityJson),
layer
.Slots.OrderBy(s => s.Weekday ?? -1)
.ThenBy(s => s.TargetStart)
.Select(slot => new SlotDto(
slot.Id,
slot.LayerId,
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Title,
slot.Daypart,
slot.SlotKind,
slot.GroupId,
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
SlotStrategy.FromJson(slot.StrategyJson),
RepeatSource.FromJson(slot.RepeatSourceJson),
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetweenId,
slot.JunctionAfterId
))
.ToList()
))
.ToList();
return Result.Success(
new ScheduleTemplateDto(
template.Id,
template.ChannelId,
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
PlanningRules.FromJson(template.RulesJson),
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
channel.UtcOffsetMinutes,
channel.DayStartTime,
layers
)
);
}
}
@@ -1,139 +1,139 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateLayerCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByTemplateAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure<Guid>(TemplateErrors.NotFound);
var layer = template.AddLayer(command.Name, command.Priority);
template.MarkChanged();
return Result.Success(layer.Id);
}
}
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateLayerCommand, Result>
{
public async Task<Result> Handle(
UpdateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
layer.Update(
command.Name,
command.Priority,
command.Applicability?.ToJson(),
command.IsEnabled
);
template.MarkChanged();
return Result.Success();
}
}
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteLayerCommand, Result>
{
public async Task<Result> Handle(
DeleteLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
if (layer.IsBackground)
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
template.RemoveLayer(command.LayerId);
template.MarkChanged();
return Result.Success();
}
}
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateTemplateCommand, Result>
{
public async Task<Result> Handle(
UpdateTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.Id == command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure(TemplateErrors.NotFound);
if (
command.FallbackGroupId is { } groupId
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
)
return Result.Failure(TemplateErrors.GroupNotFound);
template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId);
template.SetRules(command.Rules?.ToJson());
template.MarkChanged();
return Result.Success();
}
}
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
internal static class LayerLoader
{
public static Task<ScheduleTemplate?> ByTemplateAsync(
IAppDbContext dbContext,
Guid templateId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
public static Task<ScheduleTemplate?> ByLayerAsync(
IAppDbContext dbContext,
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateLayerCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByTemplateAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure<Guid>(TemplateErrors.NotFound);
var layer = template.AddLayer(command.Name, command.Priority);
template.MarkChanged();
return Result.Success(layer.Id);
}
}
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateLayerCommand, Result>
{
public async Task<Result> Handle(
UpdateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
layer.Update(
command.Name,
command.Priority,
command.Applicability?.ToJson(),
command.IsEnabled
);
template.MarkChanged();
return Result.Success();
}
}
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteLayerCommand, Result>
{
public async Task<Result> Handle(
DeleteLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
if (layer.IsBackground)
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
template.RemoveLayer(command.LayerId);
template.MarkChanged();
return Result.Success();
}
}
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateTemplateCommand, Result>
{
public async Task<Result> Handle(
UpdateTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.Id == command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure(TemplateErrors.NotFound);
if (
command.FallbackGroupId is { } groupId
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
)
return Result.Failure(TemplateErrors.GroupNotFound);
template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId);
template.SetRules(command.Rules?.ToJson());
template.MarkChanged();
return Result.Success();
}
}
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
internal static class LayerLoader
{
public static Task<ScheduleTemplate?> ByTemplateAsync(
IAppDbContext dbContext,
Guid templateId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
public static Task<ScheduleTemplate?> ByLayerAsync(
IAppDbContext dbContext,
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
}
@@ -1,67 +1,67 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
: ICommand<Result<Guid>>;
public sealed record UpdateLayerCommand(
Guid LayerId,
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
) : ICommand<Result>;
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
public sealed record UpdateTemplateCommand(
Guid TemplateId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
) : ICommand<Result>;
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
{
public CreateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
{
public UpdateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
{
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);
}
}
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
: ICommand<Result<Guid>>;
public sealed record UpdateLayerCommand(
Guid LayerId,
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
) : ICommand<Result>;
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
public sealed record UpdateTemplateCommand(
Guid TemplateId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
) : ICommand<Result>;
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
{
public CreateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
{
public UpdateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
{
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);
}
}
@@ -1,75 +1,75 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
/// </summary>
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;
}
/// <summary>
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
///
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
/// </summary>
public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null,
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
int? MaxBreakMinutesPerHour = null,
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
int? MaxGenreSharePercent = null,
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
int? MaxFallbackSharePercent = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
public ShowAudience? AudienceAt(TimeOnly moment)
{
if (MaxAudienceByTime is not { Count: > 0 } windows)
return null;
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
// отменяться более широким окном, случайно наложенным сверху.
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
return applicable.Count > 0 ? applicable.Min() : null;
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static PlanningRules? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
public sealed record RepeatLimitRule(int WindowDays, int Max);
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
/// </summary>
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;
}
/// <summary>
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
///
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
/// </summary>
public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null,
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
int? MaxBreakMinutesPerHour = null,
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
int? MaxGenreSharePercent = null,
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
int? MaxFallbackSharePercent = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
public ShowAudience? AudienceAt(TimeOnly moment)
{
if (MaxAudienceByTime is not { Count: > 0 } windows)
return null;
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
// отменяться более широким окном, случайно наложенным сверху.
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
return applicable.Count > 0 ? applicable.Min() : null;
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static PlanningRules? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
public sealed record RepeatLimitRule(int WindowDays, int Max);
@@ -1,59 +1,59 @@
using FluentValidation;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
public sealed record SlotInput(
string Title,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
/// <summary>Стык между единицами внутри блока.</summary>
Guid? JunctionBetweenId = null,
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
Guid? JunctionAfterId = null
);
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
{
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
public SlotInputValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
RuleFor(x => x.BlockValue).GreaterThan(0);
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
RuleFor(x => x.SnapToMinutes)
.Must(value => value is null || AllowedSnap.Contains(value.Value))
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
RuleFor(x => x.Strategy!.CooldownDays)
.InclusiveBetween(0, 3650)
.When(x => x.Strategy is not null);
RuleFor(x => x.RepeatSource!.DaysAgo)
.InclusiveBetween(1, 365)
.When(x => x.RepeatSource is not null);
RuleFor(x => x.RepeatSource!.DurationMinutes)
.InclusiveBetween(1, 24 * 60)
.When(x => x.RepeatSource is not null);
}
}
using FluentValidation;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
public sealed record SlotInput(
string Title,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
/// <summary>Стык между единицами внутри блока.</summary>
Guid? JunctionBetweenId = null,
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
Guid? JunctionAfterId = null
);
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
{
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
public SlotInputValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
RuleFor(x => x.BlockValue).GreaterThan(0);
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
RuleFor(x => x.SnapToMinutes)
.Must(value => value is null || AllowedSnap.Contains(value.Value))
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
RuleFor(x => x.Strategy!.CooldownDays)
.InclusiveBetween(0, 3650)
.When(x => x.Strategy is not null);
RuleFor(x => x.RepeatSource!.DaysAgo)
.InclusiveBetween(1, 365)
.When(x => x.RepeatSource is not null);
RuleFor(x => x.RepeatSource!.DurationMinutes)
.InclusiveBetween(1, 24 * 60)
.When(x => x.RepeatSource is not null);
}
}
@@ -1,110 +1,110 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
/// им нужны соседние слоты и справочник групп.
/// </summary>
public sealed class SlotWriter(IAppDbContext dbContext)
{
/// <summary>
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
/// созданием.
/// </summary>
public async Task<Result> ApplyAsync(
GridLayer layer,
Slot? slot,
SlotInput input,
CancellationToken cancellationToken
)
{
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
if (
layer.HasOverlap(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
slot?.Id
)
)
return Result.Failure(TemplateErrors.SlotsOverlap);
if (input.SlotKind == SlotKind.Content)
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.GroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
return Result.Failure(TemplateErrors.RepeatSourceRequired);
var target =
slot
?? layer.AddSlot(
input.Title,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.SlotKind,
input.Weekday
);
target.UpdateTiming(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.IsAnchor,
input.MaxDriftMinutes,
input.SnapToMinutes
);
target.UpdateContent(
new SlotContent(
input.Title,
input.SlotKind,
input.GroupId,
input.Strategy?.ToJson(),
input.RepeatSource?.ToJson(),
input.BlockMode,
input.BlockValue,
input.OverflowPolicy,
input.JunctionBetweenId,
input.JunctionAfterId
)
);
return Result.Success();
}
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
/// <summary>Загружает шаблон по слоту.</summary>
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
Guid slotId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
cancellationToken
);
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
/// им нужны соседние слоты и справочник групп.
/// </summary>
public sealed class SlotWriter(IAppDbContext dbContext)
{
/// <summary>
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
/// созданием.
/// </summary>
public async Task<Result> ApplyAsync(
GridLayer layer,
Slot? slot,
SlotInput input,
CancellationToken cancellationToken
)
{
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
if (
layer.HasOverlap(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
slot?.Id
)
)
return Result.Failure(TemplateErrors.SlotsOverlap);
if (input.SlotKind == SlotKind.Content)
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.GroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
return Result.Failure(TemplateErrors.RepeatSourceRequired);
var target =
slot
?? layer.AddSlot(
input.Title,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.SlotKind,
input.Weekday
);
target.UpdateTiming(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.IsAnchor,
input.MaxDriftMinutes,
input.SnapToMinutes
);
target.UpdateContent(
new SlotContent(
input.Title,
input.SlotKind,
input.GroupId,
input.Strategy?.ToJson(),
input.RepeatSource?.ToJson(),
input.BlockMode,
input.BlockValue,
input.OverflowPolicy,
input.JunctionBetweenId,
input.JunctionAfterId
)
);
return Result.Success();
}
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
/// <summary>Загружает шаблон по слоту.</summary>
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
Guid slotId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
cancellationToken
);
}
@@ -1,54 +1,54 @@
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
public sealed record SlotDto(
Guid Id,
Guid LayerId,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
string Title,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
string? GroupName,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
Guid? JunctionBetweenId,
Guid? JunctionAfterId
);
public sealed record GridLayerDto(
Guid Id,
string Name,
int Priority,
bool IsEnabled,
bool IsBackground,
LayerApplicability? Applicability,
IReadOnlyList<SlotDto> Slots
);
public sealed record ScheduleTemplateDto(
Guid Id,
Guid ChannelId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
PlanningRules? Rules,
int Revision,
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes,
TimeOnly DayStartTime,
IReadOnlyList<GridLayerDto> Layers
);
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
public sealed record SlotDto(
Guid Id,
Guid LayerId,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
string Title,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
string? GroupName,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
Guid? JunctionBetweenId,
Guid? JunctionAfterId
);
public sealed record GridLayerDto(
Guid Id,
string Name,
int Priority,
bool IsEnabled,
bool IsBackground,
LayerApplicability? Applicability,
IReadOnlyList<SlotDto> Slots
);
public sealed record ScheduleTemplateDto(
Guid Id,
Guid ChannelId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
PlanningRules? Rules,
int Revision,
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes,
TimeOnly DayStartTime,
IReadOnlyList<GridLayerDto> Layers
);
@@ -1,66 +1,66 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates;
public static class TemplateErrors
{
public static readonly Error NotFound = Error.NotFound(
"Templates.NotFound",
"Шаблон сетки не найден."
);
public static readonly Error LayerNotFound = Error.NotFound(
"Templates.LayerNotFound",
"Слой сетки не найден."
);
public static readonly Error SlotNotFound = Error.NotFound(
"Templates.SlotNotFound",
"Слот не найден."
);
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
"Templates.BackgroundLayerCannotBeDeleted",
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
);
public static readonly Error SlotsOverlap = Error.Conflict(
"Templates.SlotsOverlap",
"Слоты одного слоя пересекаются по времени."
);
public static readonly Error GroupRequired = Error.Validation(
"Templates.GroupRequired",
"Слоту с контентом нужна группа."
);
public static readonly Error GroupNotFound = Error.NotFound(
"Templates.GroupNotFound",
"Группа не найдена."
);
public static readonly Error JunctionNotFound = Error.NotFound(
"Templates.JunctionNotFound",
"Шаблон стыка не найден."
);
public static readonly Error JunctionElementNotFound = Error.NotFound(
"Templates.JunctionElementNotFound",
"Врезка не найдена."
);
public static readonly Error JunctionInUse = Error.Conflict(
"Templates.JunctionInUse",
"Стык используется слотами — сначала отвяжите его."
);
public static readonly Error JunctionGroupRequired = Error.Validation(
"Templates.JunctionGroupRequired",
"Врезке нужна группа, откуда брать ролики."
);
public static readonly Error RepeatSourceRequired = Error.Validation(
"Templates.RepeatSourceRequired",
"Слоту-повтору нужно указать, что повторять."
);
}
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates;
public static class TemplateErrors
{
public static readonly Error NotFound = Error.NotFound(
"Templates.NotFound",
"Шаблон сетки не найден."
);
public static readonly Error LayerNotFound = Error.NotFound(
"Templates.LayerNotFound",
"Слой сетки не найден."
);
public static readonly Error SlotNotFound = Error.NotFound(
"Templates.SlotNotFound",
"Слот не найден."
);
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
"Templates.BackgroundLayerCannotBeDeleted",
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
);
public static readonly Error SlotsOverlap = Error.Conflict(
"Templates.SlotsOverlap",
"Слоты одного слоя пересекаются по времени."
);
public static readonly Error GroupRequired = Error.Validation(
"Templates.GroupRequired",
"Слоту с контентом нужна группа."
);
public static readonly Error GroupNotFound = Error.NotFound(
"Templates.GroupNotFound",
"Группа не найдена."
);
public static readonly Error JunctionNotFound = Error.NotFound(
"Templates.JunctionNotFound",
"Шаблон стыка не найден."
);
public static readonly Error JunctionElementNotFound = Error.NotFound(
"Templates.JunctionElementNotFound",
"Врезка не найдена."
);
public static readonly Error JunctionInUse = Error.Conflict(
"Templates.JunctionInUse",
"Стык используется слотами — сначала отвяжите его."
);
public static readonly Error JunctionGroupRequired = Error.Validation(
"Templates.JunctionGroupRequired",
"Врезке нужна группа, откуда брать ролики."
);
public static readonly Error RepeatSourceRequired = Error.Validation(
"Templates.RepeatSourceRequired",
"Слоту-повтору нужно указать, что повторять."
);
}
@@ -1,328 +1,328 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
private const int MinutesInDay = 24 * 60;
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
ValidateTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
var groupIds = layers
.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken);
var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>();
foreach (var layer in layers)
{
issues.AddRange(FindOverlaps(layer, dayStart));
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
}
issues.AddRange(FindGaps(layers, dayStart));
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
}
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
private static IEnumerable<TemplateIssueDto> CheckSlot(
GridLayer layer,
Slot slot,
IReadOnlyDictionary<Guid, GroupStats> groups,
IReadOnlyDictionary<Guid, ShowAudience> strictest,
PlanningRules? rules
)
{
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupMissing,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
);
yield break;
}
if (group.ItemCount == 0)
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupEmpty,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
);
yield break;
}
var perWeek = OccurrencesPerWeek(slot);
if (group.ItemCount < perWeek)
yield return new TemplateIssueDto(
TemplateIssueKind.GroupTooSmall,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
+ "повторы пойдут чаще, чем раз в неделю."
);
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
// и если это больше состава группы, отсекать будет некого.
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
if (
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
)
yield return new TemplateIssueDto(
TemplateIssueKind.CooldownUnreachable,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
+ $"в группе «{group.Name}»."
);
if (
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
&& strictest.TryGetValue(groupId, out var groupAudience)
&& groupAudience > maxAudience
)
yield return new TemplateIssueDto(
TemplateIssueKind.AudienceConflict,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
);
}
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
/// <summary>
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
{
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
// и «телом» внешнего цикла его читает только компилятор.
for (var i = 0; i < slots.Count; i++)
{
for (var j = i + 1; j < slots.Count; j++)
{
var a = slots[i];
var b = slots[j];
if (!SameDays(a, b))
continue;
var aFrom = OffsetInDay(a.TargetStart, dayStart);
var bFrom = OffsetInDay(b.TargetStart, dayStart);
if (
aFrom < bFrom + b.TargetDurationMinutes
&& bFrom < aFrom + a.TargetDurationMinutes
)
yield return new TemplateIssueDto(
TemplateIssueKind.SlotOverlap,
TemplateIssueSeverity.Warning,
layer.Id,
b.Id,
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
);
}
}
}
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
private static bool SameDays(Slot a, Slot b) =>
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
/// <summary>
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindGaps(
IReadOnlyList<GridLayer> layers,
TimeOnly dayStart
)
{
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var intervals = layers
.SelectMany(l => l.Slots)
.Where(s => s.Weekday is null || s.Weekday == weekday)
.Select(s =>
{
var from = OffsetInDay(s.TargetStart, dayStart);
return (From: from, To: from + s.TargetDurationMinutes);
})
.OrderBy(i => i.From)
.ToList();
var cursor = 0;
foreach (var interval in intervals)
{
if (interval.From > cursor)
yield return Gap(weekday, cursor, interval.From, dayStart);
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
}
if (cursor < MinutesInDay)
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
}
}
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
{
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
var interval =
to - from >= MinutesInDay
? "весь день"
: $"{Clock(from, dayStart)}{Clock(to, dayStart)}";
return new TemplateIssueDto(
TemplateIssueKind.GridGap,
TemplateIssueSeverity.Error,
null,
null,
$"Не покрыто: {day} {interval}."
);
}
private static string Clock(int offsetMinutes, TimeOnly dayStart)
{
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
return $"{minutes / 60:00}:{minutes % 60:00}";
}
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
{
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
return diff >= 0 ? diff : diff + MinutesInDay;
}
/// <summary>
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds,
CancellationToken cancellationToken
)
{
if (groupIds.Count == 0)
return [];
var items = await dbContext
.GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId))
.Select(i => new
{
i.GroupId,
i.ElementKind,
i.ElementId,
})
.ToListAsync(cancellationToken);
var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.ToList();
var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.ToList();
var partsByCollection = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds
.Concat(partsByCollection.Select(p => p.ShowId))
.Distinct()
.ToList();
var audiences = await dbContext
.Shows.AsNoTracking()
.Where(s => neededShowIds.Contains(s.Id))
.Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
var result = new Dictionary<Guid, ShowAudience>();
foreach (var item in items)
{
var candidates =
item.ElementKind == GroupElementKind.Show
? [item.ElementId]
: partsByCollection
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
foreach (var showId in candidates)
{
if (audiences.GetValueOrDefault(showId) is not { } audience)
continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience;
}
}
return result;
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
private const int MinutesInDay = 24 * 60;
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
ValidateTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
var groupIds = layers
.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken);
var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>();
foreach (var layer in layers)
{
issues.AddRange(FindOverlaps(layer, dayStart));
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
}
issues.AddRange(FindGaps(layers, dayStart));
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
}
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
private static IEnumerable<TemplateIssueDto> CheckSlot(
GridLayer layer,
Slot slot,
IReadOnlyDictionary<Guid, GroupStats> groups,
IReadOnlyDictionary<Guid, ShowAudience> strictest,
PlanningRules? rules
)
{
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupMissing,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
);
yield break;
}
if (group.ItemCount == 0)
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupEmpty,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
);
yield break;
}
var perWeek = OccurrencesPerWeek(slot);
if (group.ItemCount < perWeek)
yield return new TemplateIssueDto(
TemplateIssueKind.GroupTooSmall,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
+ "повторы пойдут чаще, чем раз в неделю."
);
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
// и если это больше состава группы, отсекать будет некого.
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
if (
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
)
yield return new TemplateIssueDto(
TemplateIssueKind.CooldownUnreachable,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
+ $"в группе «{group.Name}»."
);
if (
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
&& strictest.TryGetValue(groupId, out var groupAudience)
&& groupAudience > maxAudience
)
yield return new TemplateIssueDto(
TemplateIssueKind.AudienceConflict,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
);
}
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
/// <summary>
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
{
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
// и «телом» внешнего цикла его читает только компилятор.
for (var i = 0; i < slots.Count; i++)
{
for (var j = i + 1; j < slots.Count; j++)
{
var a = slots[i];
var b = slots[j];
if (!SameDays(a, b))
continue;
var aFrom = OffsetInDay(a.TargetStart, dayStart);
var bFrom = OffsetInDay(b.TargetStart, dayStart);
if (
aFrom < bFrom + b.TargetDurationMinutes
&& bFrom < aFrom + a.TargetDurationMinutes
)
yield return new TemplateIssueDto(
TemplateIssueKind.SlotOverlap,
TemplateIssueSeverity.Warning,
layer.Id,
b.Id,
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
);
}
}
}
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
private static bool SameDays(Slot a, Slot b) =>
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
/// <summary>
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindGaps(
IReadOnlyList<GridLayer> layers,
TimeOnly dayStart
)
{
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var intervals = layers
.SelectMany(l => l.Slots)
.Where(s => s.Weekday is null || s.Weekday == weekday)
.Select(s =>
{
var from = OffsetInDay(s.TargetStart, dayStart);
return (From: from, To: from + s.TargetDurationMinutes);
})
.OrderBy(i => i.From)
.ToList();
var cursor = 0;
foreach (var interval in intervals)
{
if (interval.From > cursor)
yield return Gap(weekday, cursor, interval.From, dayStart);
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
}
if (cursor < MinutesInDay)
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
}
}
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
{
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
var interval =
to - from >= MinutesInDay
? "весь день"
: $"{Clock(from, dayStart)}{Clock(to, dayStart)}";
return new TemplateIssueDto(
TemplateIssueKind.GridGap,
TemplateIssueSeverity.Error,
null,
null,
$"Не покрыто: {day} {interval}."
);
}
private static string Clock(int offsetMinutes, TimeOnly dayStart)
{
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
return $"{minutes / 60:00}:{minutes % 60:00}";
}
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
{
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
return diff >= 0 ? diff : diff + MinutesInDay;
}
/// <summary>
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds,
CancellationToken cancellationToken
)
{
if (groupIds.Count == 0)
return [];
var items = await dbContext
.GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId))
.Select(i => new
{
i.GroupId,
i.ElementKind,
i.ElementId,
})
.ToListAsync(cancellationToken);
var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.ToList();
var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.ToList();
var partsByCollection = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds
.Concat(partsByCollection.Select(p => p.ShowId))
.Distinct()
.ToList();
var audiences = await dbContext
.Shows.AsNoTracking()
.Where(s => neededShowIds.Contains(s.Id))
.Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
var result = new Dictionary<Guid, ShowAudience>();
foreach (var item in items)
{
var candidates =
item.ElementKind == GroupElementKind.Show
? [item.ElementId]
: partsByCollection
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
foreach (var showId in candidates)
{
if (audiences.GetValueOrDefault(showId) is not { } audience)
continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience;
}
}
return result;
}
}