Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.AddGroupElements;
public sealed record GroupElementRef(GroupElementKind ElementKind, Guid ElementId);
/// <summary>
/// Добавляет элементы в конец группы. Массовая — так же добавляется и найденное правилом набора.
/// Уже входящие в группу молча пропускаются: при добавлении полусотни позиций падать из-за одного
/// повтора бессмысленно. Возвращает число реально добавленных.
/// </summary>
public sealed record AddGroupElementsCommand(Guid GroupId, IReadOnlyList<GroupElementRef> Elements)
: ICommand<Result<int>>;
@@ -0,0 +1,57 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.AddGroupElements;
public sealed class AddGroupElementsCommandHandler(
IAppDbContext dbContext,
GroupStatsService stats
) : ICommandHandler<AddGroupElementsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
AddGroupElementsCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure<int>(GroupErrors.NotFound);
var requested = command.Elements.Distinct().ToList();
// Ссылки полиморфные, внешнего ключа нет — проверяем существование сами, иначе в группе
// осядут позиции, которые никогда не развернутся в контент.
var showIds = requested
.Where(e => e.ElementKind == GroupElementKind.Show)
.Select(e => e.ElementId)
.ToList();
var collectionIds = requested
.Where(e => e.ElementKind == GroupElementKind.Collection)
.Select(e => e.ElementId)
.ToList();
var knownShows = await dbContext
.Shows.Where(s => showIds.Contains(s.Id))
.Select(s => s.Id)
.ToListAsync(cancellationToken);
var knownCollections = await dbContext
.Collections.Where(c => collectionIds.Contains(c.Id))
.Select(c => c.Id)
.ToListAsync(cancellationToken);
if (knownShows.Count != showIds.Count || knownCollections.Count != collectionIds.Count)
return Result.Failure<int>(GroupErrors.ElementNotFound);
var added = requested.Count(element =>
group.AddElement(element.ElementKind, element.ElementId) is not null
);
await stats.RecomputeAsync(group, cancellationToken);
return Result.Success(added);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.CreateGroup;
public sealed record CreateGroupCommand(string Name, string? Description = null)
: ICommand<Result<Guid>>;
@@ -0,0 +1,17 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.CreateGroup;
public sealed class CreateGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateGroupCommand, Result<Guid>>
{
public Task<Result<Guid>> Handle(CreateGroupCommand command, CancellationToken cancellationToken)
{
var group = Group.Create(command.Name, command.Description);
dbContext.Groups.Add(group);
return Task.FromResult(Result.Success(group.Id));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Programming.Groups.CreateGroup;
public sealed class CreateGroupCommandValidator : AbstractValidator<CreateGroupCommand>
{
public CreateGroupCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Description).MaximumLength(2048);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.DeleteGroup;
public sealed record DeleteGroupCommand(Guid GroupId) : ICommand<Result>;
@@ -0,0 +1,24 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.DeleteGroup;
public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteGroupCommand, Result>
{
public async Task<Result> Handle(DeleteGroupCommand command, CancellationToken cancellationToken)
{
var group = await dbContext.Groups.FirstOrDefaultAsync(
g => g.Id == command.GroupId,
cancellationToken
);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
// TODO(срез 1C): запретить удаление, пока на группу ссылается слот сетки.
dbContext.Groups.Remove(group);
return Result.Success();
}
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
/// <summary>
/// Подбирает позиции по правилу набора. <paramref name="Filter"/> = null — берём правило, сохранённое
/// у группы; передача фильтра явно нужна редактору, где его крутят до сохранения.
/// </summary>
public sealed record FindGroupCandidatesQuery(Guid GroupId, GroupFilter? Filter = null)
: IQuery<Result<IReadOnlyList<GroupCandidateDto>>>;
@@ -0,0 +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.Groups.FindGroupCandidates;
public sealed class FindGroupCandidatesQueryHandler(
IAppDbContext dbContext,
GroupElementResolver resolver
) : IQueryHandler<FindGroupCandidatesQuery, Result<IReadOnlyList<GroupCandidateDto>>>
{
public async Task<Result<IReadOnlyList<GroupCandidateDto>>> Handle(
FindGroupCandidatesQuery query,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == query.GroupId, cancellationToken);
if (group is null)
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.NotFound);
var filter = query.Filter ?? GroupFilter.FromJson(group.FilterJson);
if (filter is null)
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.FilterNotSet);
var wantsShows =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Show);
var wantsCollections =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Collection);
var matchingShowIds = await MatchShowIdsAsync(filter, cancellationToken);
var elements = new List<(GroupElementKind Kind, Guid Id)>();
if (wantsShows)
elements.AddRange(matchingShowIds.Select(id => (GroupElementKind.Show, id)));
if (wantsCollections)
{
// Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через
// франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов.
var collections = await dbContext
.Collections.AsNoTracking()
.Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() })
.ToListAsync(cancellationToken);
var matching = matchingShowIds.ToHashSet();
elements.AddRange(
collections
.Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains))
.Select(c => (GroupElementKind.Collection, c.Id))
);
}
var info = await resolver.ResolveAsync(elements, cancellationToken);
var inGroup = group
.Items.Select(i => (i.ElementKind, i.ElementId))
.ToHashSet();
var result = new List<GroupCandidateDto>();
foreach (var (kind, id) in elements)
{
if (!info.TryGetValue((kind, id), out var element))
continue;
if (!MatchesUnitDuration(filter, element))
continue;
result.Add(
new GroupCandidateDto(
kind,
id,
element.Name,
element.UnitCount,
element.ShowKind,
element.Audience,
element.Year,
element.PosterImageId,
inGroup.Contains((kind, id))
)
);
}
return Result.Success<IReadOnlyList<GroupCandidateDto>>(
result.OrderBy(c => c.ElementName, StringComparer.CurrentCultureIgnoreCase).ToList()
);
}
private async Task<List<Guid>> MatchShowIdsAsync(
GroupFilter filter,
CancellationToken cancellationToken
)
{
var shows = dbContext.Shows.AsNoTracking();
if (filter.ShowKinds is { Count: > 0 } kinds)
shows = shows.Where(s => kinds.Contains(s.Kind));
// Категории упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
if (filter.MaxAudience is { } maxAudience)
shows = shows.Where(s => s.Audience <= maxAudience);
if (filter.YearMin is { } yearMin)
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
if (filter.YearMax is { } yearMax)
shows = shows.Where(s => s.Year != null && s.Year <= yearMax);
// Жанры — «любой из»: шоу редко подходит под все перечисленные сразу.
if (filter.GenreIds is { Count: > 0 } genreIds)
shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId)));
return await shows.Select(s => s.Id).ToListAsync(cancellationToken);
}
/// <summary>
/// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии
/// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности
/// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё
/// обрабатывается.
/// </summary>
private static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element)
{
if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null)
return true;
if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero)
return true;
var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount;
if (filter.UnitMinutesMin is { } min && averageMinutes < min)
return false;
if (filter.UnitMinutesMax is { } max && averageMinutes > max)
return false;
return true;
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.GetGroup;
public sealed record GetGroupQuery(Guid Id) : IQuery<Result<GroupDto>>;
@@ -0,0 +1,65 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.GetGroup;
public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementResolver resolver)
: IQueryHandler<GetGroupQuery, Result<GroupDto>>
{
public async Task<Result<GroupDto>> Handle(
GetGroupQuery query,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken);
if (group is null)
return Result.Failure<GroupDto>(GroupErrors.NotFound);
var info = await resolver.ResolveAsync(
group.Items.Select(i => (i.ElementKind, i.ElementId)),
cancellationToken
);
var items = group
.Items.OrderBy(i => i.Position)
.Select(i =>
{
info.TryGetValue((i.ElementKind, i.ElementId), out var element);
return new GroupItemDto(
i.Id,
i.ElementKind,
i.ElementId,
// Элемент мог исчезнуть из библиотеки между чисткой и чтением — показываем прочерк,
// а не роняем весь экран группы.
element?.Name ?? "—",
i.Weight,
i.Position,
element?.UnitCount ?? 0,
element?.ShowKind,
element?.Audience,
element?.Year,
element?.PosterImageId
);
})
.ToList();
return Result.Success(
new GroupDto(
group.Id,
group.Name,
group.Description,
GroupFilter.FromJson(group.FilterJson),
group.ItemCount,
group.UnitCount,
group.TotalDuration.TotalSeconds,
group.StatsComputedAt,
items
)
);
}
}
@@ -0,0 +1,60 @@
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>Группа в списке — со сводкой, без состава.</summary>
public sealed record GroupSummaryDto(
Guid Id,
string Name,
string? Description,
int ItemCount,
int UnitCount,
double TotalDurationSeconds,
bool HasFilter,
DateTimeOffset? StatsComputedAt,
DateTimeOffset CreatedAt
);
/// <summary>
/// Позиция группы с данными элемента. <paramref name="UnitCount"/> — сколько единиц воспроизведения
/// она даёт: у фильма одна, у сериала и коллекции больше.
/// </summary>
public sealed record GroupItemDto(
Guid Id,
GroupElementKind ElementKind,
Guid ElementId,
string ElementName,
int Weight,
int Position,
int UnitCount,
ShowKind? ShowKind,
ShowAudience? Audience,
int? Year,
Guid? PosterImageId
);
public sealed record GroupDto(
Guid Id,
string Name,
string? Description,
GroupFilter? Filter,
int ItemCount,
int UnitCount,
double TotalDurationSeconds,
DateTimeOffset? StatsComputedAt,
IReadOnlyList<GroupItemDto> Items
);
/// <summary>Кандидат, найденный правилом набора: ещё не в группе, но подходит под фильтр.</summary>
public sealed record GroupCandidateDto(
GroupElementKind ElementKind,
Guid ElementId,
string ElementName,
int UnitCount,
ShowKind? ShowKind,
ShowAudience? Audience,
int? Year,
Guid? PosterImageId,
bool AlreadyInGroup
);
@@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>Сводка по элементу группы: как показать и сколько эфира он даёт.</summary>
public sealed record GroupElementInfo(
string Name,
int UnitCount,
TimeSpan TotalDuration,
ShowKind? ShowKind,
ShowAudience? Audience,
int? Year,
Guid? PosterImageId
);
/// <summary>
/// Разворачивает ссылки группы (шоу либо коллекция) в данные для отображения и статистики.
/// Единица воспроизведения — серия: у фильма она одна, у сериала их столько же, сколько серий,
/// у коллекции — сумма по всем её частям.
///
/// Длительность считается только по готовым ассетам: в группе может лежать шоу, чьё медиа ещё
/// обрабатывается, и включать его в «118 часов» значило бы обещать эфир, которого пока нет.
/// </summary>
public sealed class GroupElementResolver(IAppDbContext dbContext)
{
public async Task<IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo>> ResolveAsync(
IEnumerable<(GroupElementKind Kind, Guid Id)> elements,
CancellationToken cancellationToken
)
{
var requested = elements.Distinct().ToList();
if (requested.Count == 0)
return new Dictionary<(GroupElementKind, Guid), GroupElementInfo>();
var collectionIds = requested
.Where(e => e.Kind == GroupElementKind.Collection)
.Select(e => e.Id)
.ToList();
var collections = await dbContext
.Collections.AsNoTracking()
.Where(c => collectionIds.Contains(c.Id))
.Select(c => new
{
c.Id,
c.Name,
c.PosterImageId,
ShowIds = c.Items.OrderBy(i => i.Position).Select(i => i.ShowId).ToList(),
})
.ToListAsync(cancellationToken);
var showIds = requested
.Where(e => e.Kind == GroupElementKind.Show)
.Select(e => e.Id)
.Concat(collections.SelectMany(c => c.ShowIds))
.Distinct()
.ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.Kind,
s.Audience,
s.Year,
s.PosterImageId,
AssetIds = s.Episodes.Select(e => e.MediaAssetId).ToList(),
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
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);
TimeSpan DurationOf(IEnumerable<Guid> ids) =>
ids.Aggregate(
TimeSpan.Zero,
(sum, id) => durations.TryGetValue(id, out var d) ? sum + d : sum
);
var result = new Dictionary<(GroupElementKind, Guid), GroupElementInfo>();
foreach (var (kind, id) in requested)
{
if (kind == GroupElementKind.Show)
{
if (!shows.TryGetValue(id, out var show))
continue;
result[(kind, id)] = new GroupElementInfo(
show.Name,
show.AssetIds.Count,
DurationOf(show.AssetIds),
show.Kind,
show.Audience,
show.Year,
show.PosterImageId
);
continue;
}
var collection = collections.FirstOrDefault(c => c.Id == id);
if (collection is null)
continue;
var parts = collection
.ShowIds.Select(showId => shows.TryGetValue(showId, out var s) ? s : null)
.Where(s => s is not null)
.ToList();
var partAssets = parts.SelectMany(p => p!.AssetIds).ToList();
result[(kind, id)] = new GroupElementInfo(
collection.Name,
partAssets.Count,
DurationOf(partAssets),
null,
// Категория коллекции — самая строгая среди частей: по ней отбирают в детское время.
parts.Count == 0 ? null : parts.Max(p => p!.Audience),
parts.Count == 0 ? null : parts.Min(p => p!.Year),
collection.PosterImageId
);
}
return result;
}
}
@@ -0,0 +1,28 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups;
public static class GroupErrors
{
public static readonly Error NotFound = Error.NotFound("Groups.NotFound", "Группа не найдена.");
public static readonly Error ItemNotFound = Error.NotFound(
"Groups.ItemNotFound",
"Позиция в группе не найдена."
);
public static readonly Error ElementNotFound = Error.NotFound(
"Groups.ElementNotFound",
"Шоу или коллекция не найдены."
);
public static readonly Error ElementAlreadyAdded = Error.Conflict(
"Groups.ElementAlreadyAdded",
"Этот элемент уже входит в группу."
);
public static readonly Error FilterNotSet = Error.Validation(
"Groups.FilterNotSet",
"У группы не задано правило набора."
);
}
@@ -0,0 +1,48 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>
/// Правило быстрого набора состава группы. Это не запрос, выполняемый при генерации: фильтр находит
/// подходящие позиции, а добавляет их в группу отдельная команда. Пустые поля не ограничивают.
/// </summary>
public sealed record GroupFilter(
IReadOnlyList<GroupElementKind>? ElementKinds = null,
IReadOnlyList<ShowKind>? ShowKinds = null,
IReadOnlyList<Guid>? GenreIds = null,
ShowAudience? MaxAudience = null,
int? YearMin = null,
int? YearMax = null,
int? UnitMinutesMin = null,
int? UnitMinutesMax = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public string ToJson() => JsonSerializer.Serialize(this, Options);
/// <summary>Разбирает сохранённое правило. Повреждённый JSON трактуется как отсутствие фильтра:
/// группа продолжает работать своим явным составом, а админ переустановит правило.</summary>
public static GroupFilter? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<GroupFilter>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>
/// Снимает элемент со всех групп при его удалении из библиотеки. Нужен потому, что ссылка группы
/// полиморфна (шоу либо коллекция) и внешнего ключа под неё нет — каскад БД тут не сработает,
/// а висячая позиция при генерации развернулась бы в пустоту.
///
/// Затронутым группам пересчитывается статистика: иначе в инспекторе слота осталось бы обещание
/// эфира, которого больше нет.
/// </summary>
public sealed class GroupMembershipCleaner(IAppDbContext dbContext, GroupStatsService stats)
{
public async Task RemoveElementAsync(
GroupElementKind kind,
Guid elementId,
CancellationToken cancellationToken
)
{
var affectedGroupIds = await dbContext
.GroupItems.Where(i => i.ElementKind == kind && i.ElementId == elementId)
.Select(i => i.GroupId)
.Distinct()
.ToListAsync(cancellationToken);
if (affectedGroupIds.Count == 0)
return;
var groups = await dbContext
.Groups.Include(g => g.Items)
.Where(g => affectedGroupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
foreach (var group in groups)
{
group.RemoveElement(kind, elementId);
await stats.RecomputeAsync(group, cancellationToken);
}
}
}
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>
/// Пересчёт кэша статистики группы. Вызывается при каждой правке состава: цифры «342 позиции ·
/// 118 ч» видны прямо в инспекторе слота, и устаревшая статистика там хуже, чем её отсутствие.
/// </summary>
public sealed class GroupStatsService(IAppDbContext dbContext, GroupElementResolver resolver)
{
public async Task RecomputeAsync(Group group, CancellationToken cancellationToken)
{
var info = await resolver.ResolveAsync(
group.Items.Select(i => (i.ElementKind, i.ElementId)),
cancellationToken
);
var unitCount = 0;
var totalDuration = TimeSpan.Zero;
foreach (var item in group.Items)
{
if (!info.TryGetValue((item.ElementKind, item.ElementId), out var element))
continue;
unitCount += element.UnitCount;
totalDuration += element.TotalDuration;
}
group.UpdateStats(group.Items.Count, unitCount, totalDuration, DateTimeOffset.UtcNow);
}
/// <summary>Пересчитать по идентификатору, загрузив состав. Возвращает false, если группы нет.</summary>
public async Task<bool> RecomputeAsync(Guid groupId, CancellationToken cancellationToken)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == groupId, cancellationToken);
if (group is null)
return false;
await RecomputeAsync(group, cancellationToken);
return true;
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Programming.Groups.ListGroups;
public sealed record ListGroupsQuery : IQuery<IReadOnlyList<GroupSummaryDto>>;
@@ -0,0 +1,32 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Programming.Groups.ListGroups;
public sealed class ListGroupsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListGroupsQuery, IReadOnlyList<GroupSummaryDto>>
{
public async Task<IReadOnlyList<GroupSummaryDto>> Handle(
ListGroupsQuery query,
CancellationToken cancellationToken
)
{
// Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава.
return await dbContext
.Groups.AsNoTracking()
.OrderBy(g => g.Name)
.Select(g => new GroupSummaryDto(
g.Id,
g.Name,
g.Description,
g.ItemCount,
g.UnitCount,
g.TotalDuration.TotalSeconds,
g.FilterJson != null,
g.StatsComputedAt,
g.CreatedAt
))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.RemoveGroupItem;
public sealed record RemoveGroupItemCommand(Guid GroupId, Guid ItemId) : ICommand<Result>;
@@ -0,0 +1,28 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.RemoveGroupItem;
public sealed class RemoveGroupItemCommandHandler(IAppDbContext dbContext, GroupStatsService stats)
: ICommandHandler<RemoveGroupItemCommand, Result>
{
public async Task<Result> Handle(
RemoveGroupItemCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
if (!group.RemoveItem(command.ItemId))
return Result.Failure(GroupErrors.ItemNotFound);
await stats.RecomputeAsync(group, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,9 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.ReorderGroup;
/// <summary>Порядок позиций (важен для последовательных стратегий). Не упомянутые остаются после
/// перечисленных, сохраняя относительный порядок.</summary>
public sealed record ReorderGroupCommand(Guid GroupId, IReadOnlyList<Guid> ItemIdsInOrder)
: ICommand<Result>;
@@ -0,0 +1,25 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.ReorderGroup;
public sealed class ReorderGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ReorderGroupCommand, Result>
{
public async Task<Result> Handle(
ReorderGroupCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
group.Reorder(command.ItemIdsInOrder);
return Result.Success();
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.SetGroupItemWeight;
/// <summary>Вес позиции при случайном выборе (0 — исключить, не удаляя из группы).</summary>
public sealed record SetGroupItemWeightCommand(Guid GroupId, Guid ItemId, int Weight)
: ICommand<Result>;
@@ -0,0 +1,27 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.SetGroupItemWeight;
public sealed class SetGroupItemWeightCommandHandler(IAppDbContext dbContext)
: ICommandHandler<SetGroupItemWeightCommand, Result>
{
public async Task<Result> Handle(
SetGroupItemWeightCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
// Вес не влияет на объём контента — статистику пересчитывать незачем.
return group.SetWeight(command.ItemId, command.Weight)
? Result.Success()
: Result.Failure(GroupErrors.ItemNotFound);
}
}
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
/// <summary>Имя, описание и правило набора. <paramref name="Filter"/> = null снимает правило;
/// состав группы при этом не меняется.</summary>
public sealed record UpdateGroupCommand(
Guid GroupId,
string Name,
string? Description,
GroupFilter? Filter
) : ICommand<Result>;
@@ -0,0 +1,24 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
public sealed class UpdateGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateGroupCommand, Result>
{
public async Task<Result> Handle(UpdateGroupCommand command, CancellationToken cancellationToken)
{
var group = await dbContext.Groups.FirstOrDefaultAsync(
g => g.Id == command.GroupId,
cancellationToken
);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
group.Rename(command.Name, command.Description);
group.SetFilter(command.Filter?.ToJson());
return Result.Success();
}
}
@@ -0,0 +1,23 @@
using FluentValidation;
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
public sealed class UpdateGroupCommandValidator : AbstractValidator<UpdateGroupCommand>
{
public UpdateGroupCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Description).MaximumLength(2048);
When(
x => x.Filter is not null,
() =>
{
RuleFor(x => x.Filter!.YearMin).InclusiveBetween(1870, 2200).When(x => x.Filter!.YearMin is not null);
RuleFor(x => x.Filter!.YearMax).InclusiveBetween(1870, 2200).When(x => x.Filter!.YearMax is not null);
RuleFor(x => x.Filter!.UnitMinutesMin).GreaterThanOrEqualTo(0).When(x => x.Filter!.UnitMinutesMin is not null);
RuleFor(x => x.Filter!.UnitMinutesMax).GreaterThanOrEqualTo(0).When(x => x.Filter!.UnitMinutesMax is not null);
}
);
}
}
@@ -0,0 +1,20 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.ApplyTemplate;
/// <summary>
/// Применяет правила к эфиру: пересобирает будущий хвост от текущего момента. Правка слотов сама
/// по себе эфир не двигает — иначе десять правок подряд дали бы десять пересборок, и при живых
/// зрителях это было бы заметно.
/// </summary>
public sealed record ApplyChannelTemplateCommand(Guid ChannelId) : ICommand<Result<ApplyResultDto>>;
/// <summary>Итог применения: сколько записей получилось и что стоит показать админу.</summary>
public sealed record ApplyResultDto(
int Added,
IReadOnlyList<PlanningWarningDto> Warnings
);
public sealed record PlanningWarningDto(PlanningWarningKind Kind, Guid? SlotId, string Details);
@@ -0,0 +1,36 @@
using LiteCqrs;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Planning.ApplyTemplate;
public sealed class ApplyChannelTemplateCommandHandler(GridScheduleGenerator generator)
: ICommandHandler<ApplyChannelTemplateCommand, Result<ApplyResultDto>>
{
public async Task<Result<ApplyResultDto>> Handle(
ApplyChannelTemplateCommand command,
CancellationToken cancellationToken
)
{
// Генератор держит собственную транзакцию с advisory-локом канала, поэтому команда
// ограничивается вызовом и переводом результата в DTO.
var report = await generator.GenerateAsync(
command.ChannelId,
DateTimeOffset.UtcNow,
rebuildFuture: true,
cancellationToken
);
if (report.ChannelSkipped)
return Result.Failure<ApplyResultDto>(ChannelErrors.TemplateNotFound);
return Result.Success(
new ApplyResultDto(
report.Added,
report
.Warnings.Select(w => new PlanningWarningDto(w.Kind, w.SlotId, w.Details))
.ToList()
)
);
}
}
@@ -0,0 +1,192 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>Ключ отрендеренной заставки: блок, подблок и пара шоу, между которыми она стоит.</summary>
public readonly record struct BumperKey(Guid TemplateId, Guid VariantId, Guid FromShowId, Guid ToShowId);
/// <summary>
/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены
/// намеренно: ассет зависит от пары соседей, а пара известна только после того, как слоты наполнены.
///
/// Готовый ассет переиспользуется по сигнатуре (пара названий + версия блока), недостающий
/// регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру. Запись при
/// этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
/// </summary>
public sealed class BumperResolver(
IAppDbContext dbContext,
IBumperRenderQueue renderQueue,
IRandomSource random
)
{
public async Task<IReadOnlyDictionary<BumperKey, Guid>> ResolveAsync(
Channel channel,
IReadOnlyList<PlannedItem> items,
CancellationToken cancellationToken
)
{
var reserved = items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList();
if (reserved.Count == 0)
return new Dictionary<BumperKey, Guid>();
var showNames = await LoadShowNamesAsync(reserved, cancellationToken);
var result = new Dictionary<BumperKey, Guid>();
// Кэш существующих заставок канала: одна пара шоу встречается в горизонте многократно.
var existing = await dbContext
.BumperAssets.Where(b => b.ChannelId == channel.Id)
.ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
foreach (var item in reserved)
{
if (
item.BumperTemplateId is not { } templateId
|| channel.FindBumperTemplate(templateId) is not { } template
)
continue;
var fromShowId = item.FromShowId ?? Guid.Empty;
var toShowId = item.ToShowId ?? Guid.Empty;
var variant = PickVariant(template, fromShowId != toShowId, channel, random);
if (variant is null)
continue;
var key = new BumperKey(templateId, variant.Id, fromShowId, toShowId);
if (result.ContainsKey(key))
continue;
var fromName = showNames.GetValueOrDefault(fromShowId, "—");
var toName = showNames.GetValueOrDefault(toShowId, "—");
var signature = Signature(template, variant.Id, fromName, toName);
if (existing.TryGetValue(signature, out var assetId))
{
result[key] = assetId;
continue;
}
var asset = MediaAsset.RegisterGenerated($"{template.Name}: {fromName} → {toName}");
dbContext.MediaAssets.Add(asset);
dbContext.BumperAssets.Add(
BumperAsset.Create(
channel.Id,
templateId,
variant.Id,
fromShowId,
toShowId,
signature,
asset.Id
)
);
existing[signature] = asset.Id;
result[key] = asset.Id;
renderQueue.Enqueue(asset.Id);
}
return result;
}
/// <summary>
/// Подблок, подходящий под контекст перехода: на смене шоу и между сериями одного играют разные
/// тексты. Стратегия выбора — общая настройка канала.
/// </summary>
private static BumperTextVariant? PickVariant(
BumperTemplate template,
bool isShowChange,
Channel channel,
IRandomSource random
)
{
var eligible = template
.Variants.Where(v =>
v.Trigger switch
{
BumperTrigger.OnShowChange => isShowChange,
BumperTrigger.BetweenEpisodes => !isShowChange,
_ => true,
}
)
.OrderBy(v => v.Position)
.ToList();
if (eligible.Count == 0)
return null;
return channel.BumperSelection switch
{
BumperSelection.AlwaysFirst => eligible[0],
BumperSelection.Random => eligible[random.Next(eligible.Count)],
BumperSelection.WeightedRandom => WeightedPick(eligible, random),
_ => eligible[random.Next(eligible.Count)],
};
}
private static BumperTextVariant WeightedPick(
IReadOnlyList<BumperTextVariant> eligible,
IRandomSource random
)
{
var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
if (total <= 0)
return eligible[random.Next(eligible.Count)];
var roll = random.Next((int)Math.Min(total, int.MaxValue));
long accumulated = 0;
foreach (var variant in eligible)
{
accumulated += Math.Max(0, variant.Weight);
if (roll < accumulated)
return variant;
}
return eligible[^1];
}
/// <summary>
/// Сигнатура включает версию блока: замена звука или фона обязана пересобрать заставки, иначе
/// в эфире осталась бы старая картинка с новым оформлением рядом.
/// </summary>
private static string Signature(
BumperTemplate template,
Guid variantId,
string fromName,
string toName
)
{
var raw = string.Create(
CultureInfo.InvariantCulture,
$"{template.Id}|{variantId}|{template.Revision}|{fromName}|{toName}"
);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32];
}
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
IReadOnlyList<PlannedItem> reserved,
CancellationToken cancellationToken
)
{
var showIds = reserved
.SelectMany(i => new[] { i.FromShowId, i.ToShowId })
.Where(id => id is not null && id != Guid.Empty)
.Select(id => id!.Value)
.Distinct()
.ToList();
if (showIds.Count == 0)
return [];
return await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
}
}
@@ -0,0 +1,122 @@
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Planning;
/// <summary>Слот, привязанный к конкретным вещательным суткам и переведённый в UTC.</summary>
public sealed record ScheduledSlot(Slot Slot, DateOnly BroadcastDate, DateTimeOffset StartUtc);
/// <summary>
/// Разворачивает шаблон в эффективную сетку на диапазон дат: какие слоты и в какие моменты реально
/// действуют. Применимость слоёв разрешается здесь, потому что её схема (JSON) принадлежит
/// прикладному слою; чистый планировщик получает уже готовые окна в UTC.
/// </summary>
public static class EffectiveGridBuilder
{
public static IReadOnlyList<ScheduledSlot> Build(
ScheduleTemplate template,
int utcOffsetMinutes,
TimeOnly dayStartTime,
DateTimeOffset fromUtc,
DateTimeOffset toUtc
)
{
var offset = TimeSpan.FromMinutes(utcOffsetMinutes);
var result = new List<ScheduledSlot>();
// Захватываем сутки с запасом с обеих сторон: слот вещательных суток может начаться
// до fromUtc (ночной блок предыдущего дня) либо после полуночи следующих.
var firstDate = DateOnly.FromDateTime(fromUtc.ToOffset(offset).DateTime).AddDays(-1);
var lastDate = DateOnly.FromDateTime(toUtc.ToOffset(offset).DateTime).AddDays(1);
var layers = template
.Layers.Where(l => l.IsEnabled)
.OrderByDescending(l => l.Priority)
.ToList();
for (var date = firstDate; date <= lastDate; date = date.AddDays(1))
{
var applicable = layers
.Where(l =>
LayerApplicability.FromJson(l.ApplicabilityJson) is not { } applicability
|| applicability.Covers(date)
)
.ToList();
foreach (var scheduled in ResolveDay(applicable, date, offset, dayStartTime))
{
if (scheduled.StartUtc >= fromUtc.AddDays(-1) && scheduled.StartUtc < toUtc)
result.Add(scheduled);
}
}
return result.OrderBy(s => s.StartUtc).ToList();
}
/// <summary>
/// Слоты одних вещательных суток с разрешённым перекрытием слоёв. Слои идут от старшего
/// приоритета к младшему; слот младшего слоя пропускается, если его окно уже занято старшим.
///
/// Именно пропускается, а не обрезается: слот — атомарная единица со своей группой и стратегией,
/// и половина слота означала бы половину настройки.
/// </summary>
private static IEnumerable<ScheduledSlot> ResolveDay(
IReadOnlyList<GridLayer> layers,
DateOnly date,
TimeSpan offset,
TimeOnly dayStartTime
)
{
var taken = new List<(TimeSpan From, TimeSpan To)>();
var result = new List<ScheduledSlot>();
foreach (var layer in layers)
{
var slots = layer
.Slots.Where(s => s.Weekday is null || s.Weekday == (int)date.DayOfWeek)
.OrderBy(s => s.TargetStart)
.ToList();
// Слоты одного слоя добавляются все разом: внутри слоя перекрытий не бывает по построению,
// а занятые интервалы должны учитываться только от более приоритетных слоёв.
var added = new List<(TimeSpan From, TimeSpan To)>();
foreach (var slot in slots)
{
var from = OffsetInBroadcastDay(slot.TargetStart, dayStartTime);
var to = from + TimeSpan.FromMinutes(slot.TargetDurationMinutes);
if (taken.Any(t => from < t.To && t.From < to))
continue;
result.Add(new ScheduledSlot(slot, date, ToUtc(date, slot.TargetStart, offset, dayStartTime)));
added.Add((from, to));
}
taken.AddRange(added);
}
return result;
}
/// <summary>
/// Смещение времени от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток
/// в 06:00) относится к предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
/// </summary>
private static TimeSpan OffsetInBroadcastDay(TimeOnly time, TimeOnly dayStartTime) =>
time >= dayStartTime
? time.ToTimeSpan() - dayStartTime.ToTimeSpan()
: time.ToTimeSpan() - dayStartTime.ToTimeSpan() + TimeSpan.FromDays(1);
/// <summary>Момент слота в UTC. Время до начала вещательных суток попадает на следующий календарный день.</summary>
public static DateTimeOffset ToUtc(
DateOnly broadcastDate,
TimeOnly time,
TimeSpan offset,
TimeOnly dayStartTime
)
{
var calendarDate = time >= dayStartTime ? broadcastDate : broadcastDate.AddDays(1);
return new DateTimeOffset(calendarDate.ToDateTime(time), offset).ToUniversalTime();
}
}
@@ -0,0 +1,508 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>Что дала генерация: сколько записей добавлено и с какими предупреждениями.</summary>
public sealed record GenerationReport(
int Added,
IReadOnlyList<PlanningWarning> Warnings,
bool ChannelSkipped = false
);
/// <summary>
/// Оркестратор генерации по сетке: собирает конфигурацию канала, вызывает чистый планировщик,
/// материализует ленту и двигает курсоры слотов.
///
/// Генерация одного канала сериализуется advisory-блокировкой: фоновый тик и ручное применение
/// не должны читать одну точку продолжения и оба дописывать хвост.
/// </summary>
public sealed class GridScheduleGenerator(
IAppDbContext dbContext,
GroupExpander expander,
BumperResolver bumperResolver,
IRandomSource random,
IOptions<SchedulerOptions> options,
IOptions<StreamingOptions> streamingOptions
)
{
private readonly SchedulerOptions _options = options.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// Достраивает горизонт канала, а при <paramref name="rebuildFuture"/> — пересобирает будущий
/// хвост от текущего момента. Прошлое и идущая сейчас запись не трогаются никогда: зритель
/// не должен обнаружить, что у него из-под носа вырезали программу.
/// </summary>
public async Task<GenerationReport> GenerateAsync(
Guid channelId,
DateTimeOffset now,
bool rebuildFuture,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
if (channel is null || !channel.IsEnabled || channel.TemplateId is null)
return new GenerationReport(0, [], ChannelSkipped: true);
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
await dbContext.AcquireChannelLockAsync(channelId, cancellationToken);
var template = await dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken);
if (template is null)
return new GenerationReport(0, [], ChannelSkipped: true);
await CleanupAsync(channelId, now, cancellationToken);
if (rebuildFuture)
await dbContext
.ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
.ExecuteDeleteAsync(cancellationToken);
// Точка продолжения — конец последней сохранённой записи. При пересборке будущее уже удалено,
// поэтому она укажет на границу неизменяемого прошлого.
var lastEnd = await dbContext
.ScheduleEntries.Where(e => e.ChannelId == channelId)
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
var startUtc = lastEnd is { } end && end > now ? end : now;
var horizonEnd = now.AddDays(Math.Max(1, _options.HorizonDays));
if (startUtc >= horizonEnd)
{
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new GenerationReport(0, []);
}
var input = await BuildInputAsync(
channel,
template,
startUtc,
horizonEnd,
cancellationToken
);
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
// Заставки резолвятся после сборки ленты: пара соседей известна только теперь.
var bumperAssets = await bumperResolver.ResolveAsync(
channel,
result.Items,
cancellationToken
);
var added = 0;
foreach (var item in result.Items)
{
var assetId = item.MediaAssetId;
if (item.Kind == PlannedItemKind.Bumper && !TryResolveBumper(item, bumperAssets, out assetId))
continue; // Без ассета запись стала бы дырой в ленте.
dbContext.ScheduleEntries.Add(
ScheduleEntry.FromSlot(
channel.Id,
assetId,
ToEntryKind(item.Kind),
item.StartsAtUtc,
item.EndsAtUtc,
item.ShowId,
item.UnitIndex,
item.SlotId,
item.Trace is null
? null
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions)
)
);
added++;
}
await SaveCursorsAsync(result.Cursors, cancellationToken);
template.MarkApplied();
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new GenerationReport(added, result.Warnings);
}
/// <summary>
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
/// история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
private Task CleanupAsync(Guid channelId, DateTimeOffset now, CancellationToken cancellationToken)
{
var cutoff = now.AddDays(-Math.Max(1, _options.RetentionDays));
return dbContext
.ScheduleEntries.Where(e => e.ChannelId == channelId && e.EndsAtUtc < cutoff)
.ExecuteDeleteAsync(cancellationToken);
}
private async Task SaveCursorsAsync(
IReadOnlyList<PlanningCursorUpdate> cursors,
CancellationToken cancellationToken
)
{
if (cursors.Count == 0)
return;
var slotIds = cursors.Select(c => c.SlotId).Distinct().ToList();
var states = await dbContext
.SlotStates.Where(s => slotIds.Contains(s.SlotId))
.ToDictionaryAsync(s => s.SlotId, cancellationToken);
foreach (var cursor in cursors)
{
if (!states.TryGetValue(cursor.SlotId, out var state))
{
state = SlotState.Create(cursor.SlotId);
dbContext.SlotStates.Add(state);
states[cursor.SlotId] = state;
}
if (cursor.ElementKind is { } kind && cursor.ElementId is { } elementId)
state.MoveTo(kind, elementId, cursor.NextUnitIndex);
else
state.Reset();
}
}
private async Task<PlanningInput> BuildInputAsync(
Channel channel,
ScheduleTemplate template,
DateTimeOffset startUtc,
DateTimeOffset horizonEnd,
CancellationToken cancellationToken
)
{
var scheduled = EffectiveGridBuilder.Build(
template,
channel.UtcOffsetMinutes,
channel.DayStartTime,
startUtc,
horizonEnd
);
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
// что и группы контента.
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == channel.Id)
.ToDictionaryAsync(j => j.Id, cancellationToken);
var groupIds = scheduled
.Select(s => s.Slot.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Concat(
junctions
.Values.SelectMany(j => j.Elements)
.Select(e => e.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
)
.Distinct()
.ToList();
var elementsByGroup = await expander.ExpandAsync(groupIds, channel.Id, cancellationToken);
var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList();
var states = await dbContext
.SlotStates.AsNoTracking()
.Where(s => slotIds.Contains(s.SlotId))
.ToDictionaryAsync(s => s.SlotId, cancellationToken);
var slots = new List<PlanningSlot>();
foreach (var item in scheduled)
{
var slot = item.Slot;
var elements =
slot.GroupId is { } groupId
&& elementsByGroup.TryGetValue(groupId, out var groupElements)
? groupElements
: [];
var repeatUnits =
slot.SlotKind == SlotKind.Repeat
? await LoadRepeatUnitsAsync(channel, slot, item, cancellationToken)
: null;
var strategy = ToPlanningStrategy(SlotStrategy.FromJson(slot.StrategyJson));
var cursor = states.TryGetValue(slot.Id, out var state)
? new PlanningCursor(state.CurrentElementKind, state.CurrentElementId, state.NextUnitIndex)
: null;
slots.Add(
new PlanningSlot(
slot.Id,
item.StartUtc,
slot.TargetDurationMinutes,
slot.SlotKind,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
strategy,
elements,
cursor,
repeatUnits,
BuildJunction(slot.JunctionBetweenId, junctions, elementsByGroup, channel),
BuildJunction(
slot.JunctionAfterId ?? template.DefaultJunctionId,
junctions,
elementsByGroup,
channel
)
)
);
}
var fallback = await LoadFallbackUnitsAsync(channel, template, cancellationToken);
return new PlanningInput(
channel.Id,
startUtc,
horizonEnd,
slots,
fallback,
_segmentSeconds
);
}
/// <summary>Ассет заставки по зарезервированной записи; false — подобрать не удалось.</summary>
private static bool TryResolveBumper(
PlannedItem item,
IReadOnlyDictionary<BumperKey, Guid> bumperAssets,
out Guid assetId
)
{
assetId = Guid.Empty;
if (item.BumperTemplateId is not { } templateId)
return false;
// Подблок выбирает резолвер, поэтому ищем по блоку и паре шоу.
foreach (var pair in bumperAssets)
{
if (
pair.Key.TemplateId == templateId
&& pair.Key.FromShowId == (item.FromShowId ?? Guid.Empty)
&& pair.Key.ToShowId == (item.ToShowId ?? Guid.Empty)
)
{
assetId = pair.Value;
return true;
}
}
return false;
}
/// <summary>Разворачивает шаблон стыка для планировщика, включая резерв под заставки.</summary>
private PlanningJunction? BuildJunction(
Guid? junctionId,
IReadOnlyDictionary<Guid, JunctionTemplate> junctions,
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
Channel channel
)
{
if (junctionId is not { } id || !junctions.TryGetValue(id, out var template))
return null;
var elements = new List<PlanningJunctionElement>();
foreach (var element in template.Elements.OrderBy(e => e.Position))
{
var conditions =
JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions();
if (element.Kind == JunctionElementKind.Bumper)
{
// Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
// резервирует именно её, ассет подставит резолвер после сборки ленты.
if (
element.BumperTemplateId is not { } bumperTemplateId
|| channel.FindBumperTemplate(bumperTemplateId) is not { } bumperTemplate
)
continue;
var seconds = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(bumperTemplate),
_segmentSeconds
);
elements.Add(
new PlanningJunctionElement(
element.Kind,
[],
element.AmountMode,
element.AmountValue,
element.IsRequired,
conditions.OnlyOnElementChange,
conditions.MinMinutesBetween,
bumperTemplateId,
TimeSpan.FromSeconds(seconds)
)
);
continue;
}
if (
element.GroupId is not { } groupId
|| !elementsByGroup.TryGetValue(groupId, out var groupElements)
)
continue;
elements.Add(
new PlanningJunctionElement(
element.Kind,
groupElements.SelectMany(e => e.Units).ToList(),
element.AmountMode,
element.AmountValue,
element.IsRequired,
conditions.OnlyOnElementChange,
conditions.MinMinutesBetween
)
);
}
return elements.Count == 0 ? null : new PlanningJunction(id, elements);
}
/// <summary>
/// Что играло в точке, на которую ссылается слот-повтор. Читается уже записанная лента того же
/// канала — стратегий и состояния повтору не нужно.
/// </summary>
private async Task<IReadOnlyList<PlanningUnit>> LoadRepeatUnitsAsync(
Channel channel,
Slot slot,
ScheduledSlot scheduled,
CancellationToken cancellationToken
)
{
var source = RepeatSource.FromJson(slot.RepeatSourceJson);
if (source is null)
return [];
var offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
var sourceDate = scheduled.BroadcastDate.AddDays(-Math.Max(1, source.DaysAgo));
var from = EffectiveGridBuilder.ToUtc(sourceDate, source.Time, offset, channel.DayStartTime);
var to = from.AddMinutes(Math.Max(1, source.DurationMinutes));
var entries = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channel.Id
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc >= from
&& e.StartsAtUtc < to
)
.OrderBy(e => e.StartsAtUtc)
.Select(e => new
{
e.MediaAssetId,
e.ShowId,
e.EpisodeIndex,
Duration = e.EndsAtUtc - e.StartsAtUtc,
})
.ToListAsync(cancellationToken);
return entries
.Select(e => new PlanningUnit(
e.MediaAssetId,
e.Duration,
e.ShowId ?? Guid.Empty,
e.EpisodeIndex ?? 0
))
.ToList();
}
/// <summary>
/// Чем закрывать паузы. Сначала аварийная группа шаблона, затем филлер канала — последний
/// зациклен, поэтому короткая единица закрывает любой остаток лучше длинной.
/// </summary>
private async Task<IReadOnlyList<PlanningUnit>> LoadFallbackUnitsAsync(
Channel channel,
ScheduleTemplate template,
CancellationToken cancellationToken
)
{
var units = new List<PlanningUnit>();
if (template.FallbackGroupId is { } groupId)
{
var expanded = await expander.ExpandAsync([groupId], channel.Id, cancellationToken);
if (expanded.TryGetValue(groupId, out var elements))
units.AddRange(elements.SelectMany(e => e.Units));
}
if (channel.FillerAssetId is { } fillerId)
{
var filler = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
a.Id == fillerId && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.FirstOrDefaultAsync(cancellationToken);
if (filler is not null)
units.Add(new PlanningUnit(filler.Id, filler.Duration!.Value, Guid.Empty, 0));
}
// Короткие вперёд: чем короче единица, тем меньше остаётся незакрытым остаток паузы.
return units.OrderBy(u => u.Duration).ToList();
}
private static PlanningStrategy ToPlanningStrategy(SlotStrategy? strategy)
{
if (strategy is null)
return new PlanningStrategy(SlotStrategyKind.Sequential);
var kind = strategy.Type switch
{
SlotStrategyType.RandomWithCooldown => SlotStrategyKind.RandomWithCooldown,
SlotStrategyType.Fixed => SlotStrategyKind.Fixed,
_ => SlotStrategyKind.Sequential,
};
return new PlanningStrategy(
kind,
strategy.RestartOnEnd,
strategy.CooldownDays,
strategy.Fallback == CooldownFallback.IgnoreCooldown,
strategy.FixedElementId
);
}
private static ScheduleEntryKind ToEntryKind(PlannedItemKind kind) =>
kind switch
{
PlannedItemKind.SignOff => ScheduleEntryKind.SignOff,
PlannedItemKind.Fallback => ScheduleEntryKind.Fallback,
PlannedItemKind.Ad or PlannedItemKind.Promo => ScheduleEntryKind.Ad,
PlannedItemKind.Bumper => ScheduleEntryKind.Bumper,
_ => ScheduleEntryKind.Program,
};
}
@@ -0,0 +1,171 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken
)
{
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 episodes = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
Episodes = s
.Episodes.OrderBy(e => e.Position)
.Select(e => new { e.MediaAssetId, e.Position })
.ToList(),
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
List<PlanningUnit> UnitsOfShow(Guid showId)
{
if (!episodes.TryGetValue(showId, out var show))
return [];
return show
.Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
.Select((e, index) => new PlanningUnit(
e.MediaAssetId,
durations[e.MediaAssetId],
showId,
index
))
.ToList();
}
foreach (var group in groups)
{
var elements = new List<PlanningElement>();
foreach (var item in group.Items.OrderBy(i => i.Position))
{
if (item.ElementKind == GroupElementKind.Show)
{
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOfShow(item.ElementId),
lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null
)
);
continue;
}
var partShowIds = collectionParts
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
var units = partShowIds.SelectMany(UnitsOfShow).ToList();
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
units,
// Остывание коллекции считается по самой свежей из её частей: показ любой
// из них означает, что франшиза недавно была в эфире.
partShowIds
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
.Where(p => p is not null)
.DefaultIfEmpty(null)
.Max()
)
);
}
result[group.Id] = elements;
}
return result;
}
/// <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);
}
@@ -0,0 +1,13 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.CreateSlot;
public sealed record CreateSlotCommand(Guid LayerId, SlotInput Input) : ICommand<Result<Guid>>;
public sealed class CreateSlotCommandValidator : AbstractValidator<CreateSlotCommand>
{
public CreateSlotCommandValidator() =>
RuleFor(x => x.Input).NotNull().SetValidator(new SlotInputValidator());
}
@@ -0,0 +1,31 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.CreateSlot;
public sealed class CreateSlotCommandHandler(SlotWriter writer)
: ICommandHandler<CreateSlotCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateSlotCommand command,
CancellationToken cancellationToken
)
{
var template = await writer.LoadTemplateByLayerAsync(command.LayerId, cancellationToken);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure<Guid>(TemplateErrors.LayerNotFound);
var before = layer.Slots.Select(s => s.Id).ToHashSet();
var applied = await writer.ApplyAsync(layer, null, command.Input, cancellationToken);
if (!applied.IsSuccess)
return Result.Failure<Guid>(applied.Error);
// Правка правил эфир не двигает — только помечает шаблон изменённым (см. применение по кнопке).
template.MarkChanged();
var created = layer.Slots.First(s => !before.Contains(s.Id));
return Result.Success(created.Id);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.DeleteSlot;
public sealed record DeleteSlotCommand(Guid SlotId) : ICommand<Result>;
@@ -0,0 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.DeleteSlot;
public sealed class DeleteSlotCommandHandler(SlotWriter writer)
: ICommandHandler<DeleteSlotCommand, Result>
{
public async Task<Result> Handle(DeleteSlotCommand command, CancellationToken cancellationToken)
{
var template = await writer.LoadTemplateBySlotAsync(command.SlotId, cancellationToken);
var layer = template?.FindLayerOfSlot(command.SlotId);
if (template is null || layer is null || !layer.RemoveSlot(command.SlotId))
return Result.Failure(TemplateErrors.SlotNotFound);
template.MarkChanged();
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.GetTemplate;
public sealed record GetChannelTemplateQuery(Guid ChannelId) : IQuery<Result<ScheduleTemplateDto>>;
@@ -0,0 +1,100 @@
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)
.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,
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
channel.UtcOffsetMinutes,
channel.DayStartTime,
layers
)
);
}
}
@@ -0,0 +1,40 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Условия показа врезки. Хранятся структурно, а не выражением: парсер выражений, его валидация
/// и отдельный UI обошлись бы дорого, а покрывают ровно те же несколько реальных случаев.
/// </summary>
public sealed record JunctionConditions(
/// <summary>Ставить только при смене шоу, а не между сериями одного.</summary>
bool OnlyOnElementChange = false,
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
int MinMinutesBetween = 0
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static JunctionConditions? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<JunctionConditions>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
@@ -0,0 +1,64 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed record ListJunctionsQuery(Guid ChannelId)
: IQuery<IReadOnlyList<JunctionTemplateDto>>;
public sealed record CreateJunctionCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
public sealed record RenameJunctionCommand(Guid JunctionId, string Name) : ICommand<Result>;
public sealed record DeleteJunctionCommand(Guid JunctionId) : ICommand<Result>;
public sealed record AddJunctionElementCommand(Guid JunctionId, JunctionElementKind Kind)
: ICommand<Result<Guid>>;
/// <summary>Полный набор настроек врезки — то же тело для правки любого элемента стыка.</summary>
public sealed record JunctionElementInput(
JunctionElementKind Kind,
Guid? GroupId,
Guid? BumperTemplateId,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
JunctionConditions? Conditions
);
public sealed record UpdateJunctionElementCommand(
Guid JunctionId,
Guid ElementId,
JunctionElementInput Input
) : ICommand<Result>;
public sealed record RemoveJunctionElementCommand(Guid JunctionId, Guid ElementId)
: ICommand<Result>;
public sealed record ReorderJunctionCommand(Guid JunctionId, IReadOnlyList<Guid> ElementIdsInOrder)
: ICommand<Result>;
public sealed class CreateJunctionCommandValidator : AbstractValidator<CreateJunctionCommand>
{
public CreateJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
}
public sealed class RenameJunctionCommandValidator : AbstractValidator<RenameJunctionCommand>
{
public RenameJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
}
public sealed class UpdateJunctionElementCommandValidator
: AbstractValidator<UpdateJunctionElementCommand>
{
public UpdateJunctionElementCommandValidator()
{
// Верхняя граница — сутки: врезка длиннее вещательного дня бессмысленна.
RuleFor(x => x.Input.AmountValue).InclusiveBetween(1, 24 * 60);
RuleFor(x => x.Input.Conditions!.MinMinutesBetween)
.InclusiveBetween(0, 24 * 60)
.When(x => x.Input.Conditions is not null);
}
}
@@ -0,0 +1,23 @@
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed record JunctionElementDto(
Guid Id,
int Position,
JunctionElementKind Kind,
Guid? GroupId,
string? GroupName,
Guid? BumperTemplateId,
string? BumperTemplateName,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
JunctionConditions? Conditions
);
public sealed record JunctionTemplateDto(
Guid Id,
string Name,
IReadOnlyList<JunctionElementDto> Elements
);
@@ -0,0 +1,297 @@
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.Junctions;
public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListJunctionsQuery, IReadOnlyList<JunctionTemplateDto>>
{
public async Task<IReadOnlyList<JunctionTemplateDto>> Handle(
ListJunctionsQuery query,
CancellationToken cancellationToken
)
{
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == query.ChannelId)
.OrderBy(j => j.Name)
.ToListAsync(cancellationToken);
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
var groupIds = junctions
.SelectMany(j => j.Elements)
.Select(e => e.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 bumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == query.ChannelId)
.SelectMany(c => c.BumperTemplates)
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
return junctions
.Select(j => new JunctionTemplateDto(
j.Id,
j.Name,
j.Elements.OrderBy(e => e.Position)
.Select(e => new JunctionElementDto(
e.Id,
e.Position,
e.Kind,
e.GroupId,
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
e.BumperTemplateId,
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
? bname
: null,
e.AmountMode,
e.AmountValue,
e.IsRequired,
JunctionConditions.FromJson(e.ConditionsJson)
))
.ToList()
))
.ToList();
}
}
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateJunctionCommand command,
CancellationToken cancellationToken
)
{
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
return Result.Failure<Guid>(ChannelErrors.NotFound);
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
dbContext.JunctionTemplates.Add(junction);
return Result.Success(junction.Id);
}
}
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RenameJunctionCommand, Result>
{
public async Task<Result> Handle(
RenameJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Rename(command.Name);
return await MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
}
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
internal static async Task<Result> MarkTemplateChangedAsync(
IAppDbContext dbContext,
JunctionTemplate junction,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.ChannelId == junction.ChannelId,
cancellationToken
);
template?.MarkChanged();
return Result.Success();
}
}
public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteJunctionCommand, Result>
{
public async Task<Result> Handle(
DeleteJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
// Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
var used = await dbContext.Slots.AnyAsync(
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
cancellationToken
);
if (used)
return Result.Failure(TemplateErrors.JunctionInUse);
dbContext.JunctionTemplates.Remove(junction);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddJunctionElementCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
var element = junction.AddElement(command.Kind);
await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
return Result.Success(element.Id);
}
}
public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateJunctionElementCommand, Result>
{
public async Task<Result> Handle(
UpdateJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
var element = junction?.FindElement(command.ElementId);
if (junction is null || element is null)
return Result.Failure(TemplateErrors.JunctionElementNotFound);
var input = command.Input;
if (input.Kind == JunctionElementKind.Bumper)
{
var known = await dbContext
.Channels.Where(c => c.Id == junction.ChannelId)
.SelectMany(c => c.BumperTemplates)
.AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
if (!known)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
}
else
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.JunctionGroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
element.Update(
input.Kind,
input.GroupId,
input.BumperTemplateId,
input.AmountMode,
input.AmountValue,
input.IsRequired,
input.Conditions?.ToJson()
);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveJunctionElementCommand, Result>
{
public async Task<Result> Handle(
RemoveJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null || !junction.RemoveElement(command.ElementId))
return Result.Failure(TemplateErrors.JunctionElementNotFound);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ReorderJunctionCommand, Result>
{
public async Task<Result> Handle(
ReorderJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Reorder(command.ElementIdsInOrder);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
internal static class JunctionLoader
{
public static Task<JunctionTemplate?> LoadAsync(
IAppDbContext dbContext,
Guid junctionId,
CancellationToken cancellationToken
) =>
dbContext
.JunctionTemplates.Include(j => j.Elements)
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
}
@@ -0,0 +1,87 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Разовый диапазон дат.</summary>
public sealed record DateRange(DateOnly From, DateOnly To);
/// <summary>Ежегодно повторяющийся период — «перед Новым годом» задаётся один раз, а не каждый год.</summary>
public sealed record AnnualRange(int FromMonth, int FromDay, int ToMonth, int ToDay);
/// <summary>
/// Когда действует слой сетки. Пустая применимость — слой действует всегда. Заполненные разделы
/// объединяются по ИЛИ: слой применим, если дата подходит хотя бы под одно из условий.
/// </summary>
public sealed record LayerApplicability(
/// <summary>Дни недели вещательных суток (0=Вс..6=Сб).</summary>
IReadOnlyList<int>? Weekdays = null,
IReadOnlyList<DateRange>? DateRanges = null,
IReadOnlyList<AnnualRange>? AnnualRanges = null,
IReadOnlyList<DateOnly>? SpecificDates = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public bool IsEmpty =>
Weekdays is null or { Count: 0 }
&& DateRanges is null or { Count: 0 }
&& AnnualRanges is null or { Count: 0 }
&& SpecificDates is null or { Count: 0 };
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static LayerApplicability? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<LayerApplicability>(json, Options);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Действует ли слой в эту дату (дата вещательных суток, не календарная — ночь после полуночи
/// принадлежит предыдущему дню).
/// </summary>
public bool Covers(DateOnly date)
{
if (IsEmpty)
return true;
if (Weekdays is { Count: > 0 } weekdays && weekdays.Contains((int)date.DayOfWeek))
return true;
if (SpecificDates is { Count: > 0 } dates && dates.Contains(date))
return true;
if (DateRanges is { Count: > 0 } ranges && ranges.Any(r => date >= r.From && date <= r.To))
return true;
return AnnualRanges is { Count: > 0 } annual && annual.Any(r => CoversAnnual(r, date));
}
/// <summary>
/// Попадает ли дата в ежегодный период. Период может пересекать Новый год («20 декабря —
/// 8 января»), поэтому сравнение идёт по паре (месяц, день), а не по датам.
/// </summary>
private static bool CoversAnnual(AnnualRange range, DateOnly date)
{
var value = date.Month * 100 + date.Day;
var from = range.FromMonth * 100 + range.FromDay;
var to = range.ToMonth * 100 + range.ToDay;
return from <= to ? value >= from && value <= to : value >= from || value <= to;
}
}
@@ -0,0 +1,122 @@
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.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)
.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)
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
}
@@ -0,0 +1,49 @@
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
) : 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);
}
@@ -0,0 +1,35 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Откуда слот-повтор берёт содержимое: точка в уже записанной ленте того же канала. «Вечерний фильм
/// утром в субботу» — это <c>daysAgo = 1, time = 20:00</c>.
/// </summary>
public sealed record RepeatSource(int DaysAgo, TimeOnly Time, int DurationMinutes)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static RepeatSource? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<RepeatSource>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
@@ -0,0 +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);
}
}
@@ -0,0 +1,72 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Как слот выбирает элемент группы.</summary>
public enum SlotStrategyType
{
/// <summary>Элементы группы по порядку позиций.</summary>
Sequential = 0,
/// <summary>Случайный элемент, не показанный последние N дней.</summary>
RandomWithCooldown = 1,
/// <summary>Всегда один и тот же элемент.</summary>
Fixed = 2,
}
/// <summary>Что делать, когда остывание отсекло всех кандидатов.</summary>
public enum CooldownFallback
{
/// <summary>Взять элемент, не игравший дольше всех.</summary>
OldestFirst = 0,
/// <summary>Проигнорировать остывание на этот выход.</summary>
IgnoreCooldown = 1,
}
/// <summary>
/// Стратегия выбора элемента внутри слота. Отвечает только за выбор элемента: единицы внутри элемента
/// всегда идут по порядку от курсора — сериал не должен прыгать по сериям.
///
/// Размер блока живёт на слоте отдельно и ортогонален стратегии: любую можно скомбинировать
/// с «одна единица», «четыре подряд» или «пока не наберётся два часа».
/// </summary>
public sealed record SlotStrategy(
SlotStrategyType Type = SlotStrategyType.Sequential,
/// <summary>Начинать группу заново, дойдя до конца (иначе слот останавливается).</summary>
bool RestartOnEnd = true,
/// <summary>Дней остывания для <see cref="SlotStrategyType.RandomWithCooldown"/>.</summary>
int CooldownDays = 0,
CooldownFallback Fallback = CooldownFallback.OldestFirst,
/// <summary>Закреплённый элемент для <see cref="SlotStrategyType.Fixed"/>.</summary>
Guid? FixedElementId = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public string ToJson() => JsonSerializer.Serialize(this, Options);
/// <summary>Разбирает сохранённую стратегию. Повреждённый JSON — как отсутствие стратегии:
/// слот получит поведение по умолчанию, а не уронит генерацию всего канала.</summary>
public static SlotStrategy? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<SlotStrategy>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
@@ -0,0 +1,106 @@
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(
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)
.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)
.FirstOrDefaultAsync(
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
cancellationToken
);
}
@@ -0,0 +1,52 @@
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,
int Revision,
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes,
TimeOnly DayStartTime,
IReadOnlyList<GridLayerDto> Layers
);
@@ -0,0 +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",
"Слоту-повтору нужно указать, что повторять."
);
}
@@ -0,0 +1,13 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.UpdateSlot;
public sealed record UpdateSlotCommand(Guid SlotId, SlotInput Input) : ICommand<Result>;
public sealed class UpdateSlotCommandValidator : AbstractValidator<UpdateSlotCommand>
{
public UpdateSlotCommandValidator() =>
RuleFor(x => x.Input).NotNull().SetValidator(new SlotInputValidator());
}
@@ -0,0 +1,24 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.UpdateSlot;
public sealed class UpdateSlotCommandHandler(SlotWriter writer)
: ICommandHandler<UpdateSlotCommand, Result>
{
public async Task<Result> Handle(UpdateSlotCommand command, CancellationToken cancellationToken)
{
var template = await writer.LoadTemplateBySlotAsync(command.SlotId, cancellationToken);
var layer = template?.FindLayerOfSlot(command.SlotId);
var slot = layer?.FindSlot(command.SlotId);
if (template is null || layer is null || slot is null)
return Result.Failure(TemplateErrors.SlotNotFound);
var applied = await writer.ApplyAsync(layer, slot, command.Input, cancellationToken);
if (!applied.IsSuccess)
return applied;
template.MarkChanged();
return Result.Success();
}
}