Normalize line endings to LF via .gitattributes

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:37:33 +03:00
co-authored by Claude Opus 5
parent 9d1c6d2fc3
commit 0442056367
109 changed files with 13469 additions and 13459 deletions
@@ -1,126 +1,126 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
IBumperTemplateStorage storage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : ICommandHandler<RenderBumperPreviewCommand, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> Handle(
RenderBumperPreviewCommand query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
var seconds = template.AudioDurationSeconds is { } d and > 0
? d
: DefaultBumperDurationSeconds;
var aligned = (int)(
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
);
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
var inputs = new BumperSpecInputs(
fromName,
toName,
audioPath,
PosterAbsolutePath: null,
backgroundPath
);
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position))
{
var spec = BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
inputs
);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
}
return Result.Success();
}
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
private async Task<string?> ResolveBackgroundPathAsync(
BumperTemplate template,
CancellationToken cancellationToken
)
{
if (template.BackgroundImageId is not { } imageId)
return null;
var extension = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == imageId)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
}
/// <summary>
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
/// </summary>
private async Task<(string From, string To)> SampleNamesAsync(
Channel channel,
CancellationToken cancellationToken
)
{
var names = await (
from slot in dbContext.Slots.AsNoTracking()
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
where
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
select show.Name
)
.Distinct()
.Take(2)
.ToListAsync(cancellationToken);
return (
names.ElementAtOrDefault(0) ?? "Первое шоу",
names.ElementAtOrDefault(1) ?? "Второе шоу"
);
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
IBumperTemplateStorage storage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : ICommandHandler<RenderBumperPreviewCommand, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> Handle(
RenderBumperPreviewCommand query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
var seconds = template.AudioDurationSeconds is { } d and > 0
? d
: DefaultBumperDurationSeconds;
var aligned = (int)(
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
);
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
var inputs = new BumperSpecInputs(
fromName,
toName,
audioPath,
PosterAbsolutePath: null,
backgroundPath
);
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position))
{
var spec = BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
inputs
);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
}
return Result.Success();
}
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
private async Task<string?> ResolveBackgroundPathAsync(
BumperTemplate template,
CancellationToken cancellationToken
)
{
if (template.BackgroundImageId is not { } imageId)
return null;
var extension = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == imageId)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
}
/// <summary>
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
/// </summary>
private async Task<(string From, string To)> SampleNamesAsync(
Channel channel,
CancellationToken cancellationToken
)
{
var names = await (
from slot in dbContext.Slots.AsNoTracking()
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
where
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
select show.Name
)
.Distinct()
.Take(2)
.ToListAsync(cancellationToken);
return (
names.ElementAtOrDefault(0) ?? "Первое шоу",
names.ElementAtOrDefault(1) ?? "Второе шоу"
);
}
}
@@ -1,64 +1,64 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
public sealed record BumperTemplateDto(
Guid Id,
int Position,
bool IsDefault,
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor,
Guid? BackgroundImageId,
bool HasAudio,
double? AudioDurationSeconds,
IReadOnlyList<BumperTextVariantDto> Variants
);
public sealed record ChannelDto(
Guid Id,
string Name,
string Slug,
bool IsEnabled,
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime,
Guid? TemplateId,
bool BumpersEnabled,
BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId,
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
ViewerSettingsDto Viewer
);
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
public sealed record ViewerSettingsDto(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
public sealed record BumperTemplateDto(
Guid Id,
int Position,
bool IsDefault,
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor,
Guid? BackgroundImageId,
bool HasAudio,
double? AudioDurationSeconds,
IReadOnlyList<BumperTextVariantDto> Variants
);
public sealed record ChannelDto(
Guid Id,
string Name,
string Slug,
bool IsEnabled,
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime,
Guid? TemplateId,
bool BumpersEnabled,
BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId,
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
ViewerSettingsDto Viewer
);
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
public sealed record ViewerSettingsDto(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
@@ -1,53 +1,53 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast;
public static class ChannelErrors
{
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
public static readonly Error NumberTaken = Error.Conflict(
"Channels.NumberTaken",
"Канал с таким номером уже есть."
);
public static readonly Error TemplateNotFound = Error.NotFound(
"Channels.TemplateNotFound",
"У канала нет шаблона сетки."
);
public static readonly Error DuplicateSlug = Error.Conflict(
"Channels.DuplicateSlug",
"Канал с таким slug уже существует."
);
public static readonly Error BumperTemplateNotFound = Error.NotFound(
"Channels.BumperTemplateNotFound",
"Блок заставки не найден."
);
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
"Channels.CannotRemoveDefaultBumperTemplate",
"Дефолтный блок заставки удалить нельзя."
);
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
"Channels.BumperTextVariantNotFound",
"Подблок заставки не найден."
);
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
"Channels.CannotRemoveLastBumperTextVariant",
"Нельзя удалить последний подблок — нужен хотя бы один."
);
public static readonly Error AssetNotFound = Error.NotFound(
"Channels.AssetNotFound",
"Медиа-ассет не найден."
);
public static readonly Error InvalidBumperFile = Error.Validation(
"Channels.InvalidBumperFile",
"Недопустимый файл заставки (формат или размер)."
);
}
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast;
public static class ChannelErrors
{
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
public static readonly Error NumberTaken = Error.Conflict(
"Channels.NumberTaken",
"Канал с таким номером уже есть."
);
public static readonly Error TemplateNotFound = Error.NotFound(
"Channels.TemplateNotFound",
"У канала нет шаблона сетки."
);
public static readonly Error DuplicateSlug = Error.Conflict(
"Channels.DuplicateSlug",
"Канал с таким slug уже существует."
);
public static readonly Error BumperTemplateNotFound = Error.NotFound(
"Channels.BumperTemplateNotFound",
"Блок заставки не найден."
);
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
"Channels.CannotRemoveDefaultBumperTemplate",
"Дефолтный блок заставки удалить нельзя."
);
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
"Channels.BumperTextVariantNotFound",
"Подблок заставки не найден."
);
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
"Channels.CannotRemoveLastBumperTextVariant",
"Нельзя удалить последний подблок — нужен хотя бы один."
);
public static readonly Error AssetNotFound = Error.NotFound(
"Channels.AssetNotFound",
"Медиа-ассет не найден."
);
public static readonly Error InvalidBumperFile = Error.Validation(
"Channels.InvalidBumperFile",
"Недопустимый файл заставки (формат или размер)."
);
}
@@ -1,82 +1,82 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.GetChannel;
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
{
public async Task<Result<ChannelDto>> Handle(
GetChannelQuery query,
CancellationToken cancellationToken
)
{
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null)
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
var bumperTemplates = channel
.BumperTemplates.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto(
t.Id,
t.Position,
t.IsDefault,
t.Name,
t.BackgroundColor,
t.BackgroundColor2,
t.AccentColor,
t.TextColor,
t.BackgroundImageId,
t.AudioExtension is not null,
t.AudioDurationSeconds,
t.Variants.OrderBy(v => v.Position)
.Select(v => new BumperTextVariantDto(
v.Id,
v.Position,
v.Name,
v.Kind,
v.NowLabel,
v.NextLabel,
v.Line1,
v.Line2,
v.Trigger,
v.Weight
))
.ToList()
))
.ToList();
return Result.Success(
new ChannelDto(
channel.Id,
channel.Name,
channel.Slug,
channel.IsEnabled,
channel.Number,
channel.UtcOffsetMinutes,
channel.DayStartTime,
channel.TemplateId,
channel.BumpersEnabled,
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
bumperTemplates,
channel.FillerAssetId,
new ViewerSettingsDto(
channel.LogoImageId,
channel.LogoCorner,
channel.LogoOpacity,
channel.ShowClock,
channel.AnalogFilterStrength
)
)
);
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.GetChannel;
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
{
public async Task<Result<ChannelDto>> Handle(
GetChannelQuery query,
CancellationToken cancellationToken
)
{
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null)
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
var bumperTemplates = channel
.BumperTemplates.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto(
t.Id,
t.Position,
t.IsDefault,
t.Name,
t.BackgroundColor,
t.BackgroundColor2,
t.AccentColor,
t.TextColor,
t.BackgroundImageId,
t.AudioExtension is not null,
t.AudioDurationSeconds,
t.Variants.OrderBy(v => v.Position)
.Select(v => new BumperTextVariantDto(
v.Id,
v.Position,
v.Name,
v.Kind,
v.NowLabel,
v.NextLabel,
v.Line1,
v.Line2,
v.Trigger,
v.Weight
))
.ToList()
))
.ToList();
return Result.Success(
new ChannelDto(
channel.Id,
channel.Name,
channel.Slug,
channel.IsEnabled,
channel.Number,
channel.UtcOffsetMinutes,
channel.DayStartTime,
channel.TemplateId,
channel.BumpersEnabled,
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
bumperTemplates,
channel.FillerAssetId,
new ViewerSettingsDto(
channel.LogoImageId,
channel.LogoCorner,
channel.LogoOpacity,
channel.ShowClock,
channel.AnalogFilterStrength
)
)
);
}
}
@@ -1,21 +1,21 @@
namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
/// <summary>
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
/// </summary>
public int HorizonDays { get; init; } = 7;
/// <summary>
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
public int RetentionDays { get; init; } = 90;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
/// <summary>
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
/// </summary>
public int HorizonDays { get; init; } = 7;
/// <summary>
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
public int RetentionDays { get; init; } = 90;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
@@ -1,18 +1,18 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed record UpdateChannelSettingsCommand(
Guid ChannelId,
string Name,
bool IsEnabled,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
) : ICommand<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
/// показа сюда не входят — они задаются на элементе стыка.</summary>
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed record UpdateChannelSettingsCommand(
Guid ChannelId,
string Name,
bool IsEnabled,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
) : ICommand<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
/// показа сюда не входят — они задаются на элементе стыка.</summary>
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
@@ -1,42 +1,42 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelSettingsCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
if (command.FillerAssetId is { } fillerId)
{
var exists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == fillerId,
cancellationToken
);
if (!exists)
return Result.Failure(ChannelErrors.AssetNotFound);
}
channel.UpdateSettings(
command.Name,
command.IsEnabled,
command.BumpersEnabled,
command.FillerAssetId
);
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
return Result.Success();
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelSettingsCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
if (command.FillerAssetId is { } fillerId)
{
var exists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == fillerId,
cancellationToken
);
if (!exists)
return Result.Failure(ChannelErrors.AssetNotFound);
}
channel.UpdateSettings(
command.Name,
command.IsEnabled,
command.BumpersEnabled,
command.FillerAssetId
);
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
return Result.Success();
}
}
@@ -1,12 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand>
{
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
}
}
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand>
{
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
}
}
@@ -1,48 +1,48 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
namespace TeleWave.Application.Common.Interfaces;
public interface IAppDbContext
{
DbSet<RefreshToken> RefreshTokens { get; }
DbSet<MediaAsset> MediaAssets { get; }
DbSet<Show> Shows { get; }
DbSet<Genre> Genres { get; }
DbSet<GenreAlias> GenreAliases { get; }
DbSet<ShowGenre> ShowGenres { get; }
DbSet<Collection> Collections { get; }
DbSet<CollectionItem> CollectionItems { get; }
DbSet<Group> Groups { get; }
DbSet<GroupItem> GroupItems { get; }
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
DbSet<GridLayer> GridLayers { get; }
DbSet<Slot> Slots { get; }
DbSet<SlotState> SlotStates { get; }
DbSet<JunctionTemplate> JunctionTemplates { get; }
DbSet<JunctionElement> JunctionElements { get; }
DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
/// Вызывать внутри открытой транзакции.</summary>
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
namespace TeleWave.Application.Common.Interfaces;
public interface IAppDbContext
{
DbSet<RefreshToken> RefreshTokens { get; }
DbSet<MediaAsset> MediaAssets { get; }
DbSet<Show> Shows { get; }
DbSet<Genre> Genres { get; }
DbSet<GenreAlias> GenreAliases { get; }
DbSet<ShowGenre> ShowGenres { get; }
DbSet<Collection> Collections { get; }
DbSet<CollectionItem> CollectionItems { get; }
DbSet<Group> Groups { get; }
DbSet<GroupItem> GroupItems { get; }
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
DbSet<GridLayer> GridLayers { get; }
DbSet<Slot> Slots { get; }
DbSet<SlotState> SlotStates { get; }
DbSet<JunctionTemplate> JunctionTemplates { get; }
DbSet<JunctionElement> JunctionElements { get; }
DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
/// Вызывать внутри открытой транзакции.</summary>
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
}
@@ -1,63 +1,63 @@
using TeleWave.Domain.Media;
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
/// </summary>
public interface IMediaStorage
{
/// <summary>Свободное место на томе хранилища, байт.</summary>
long GetAvailableFreeSpaceBytes();
/// <summary>
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
/// </summary>
Task<string> SaveUploadAsync(
Stream content,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
/// <summary>Файл ручного inbox.</summary>
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
/// <summary>
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
/// каталог наполняет человек, и он может оказаться большим.
/// </summary>
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
/// не трогает — рядом может лежать следующая серия.
/// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
/// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
/// переносит — файл из каталога-источника уходит.
/// </summary>
Task PromoteToOriginalAsync(
MediaSource source,
string sourceToken,
Guid assetId,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
Task DeleteAssetArtifactsAsync(
Guid assetId,
string extension,
CancellationToken cancellationToken
);
}
using TeleWave.Domain.Media;
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
/// </summary>
public interface IMediaStorage
{
/// <summary>Свободное место на томе хранилища, байт.</summary>
long GetAvailableFreeSpaceBytes();
/// <summary>
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
/// </summary>
Task<string> SaveUploadAsync(
Stream content,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
/// <summary>Файл ручного inbox.</summary>
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
/// <summary>
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
/// каталог наполняет человек, и он может оказаться большим.
/// </summary>
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
/// не трогает — рядом может лежать следующая серия.
/// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
/// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
/// переносит — файл из каталога-источника уходит.
/// </summary>
Task PromoteToOriginalAsync(
MediaSource source,
string sourceToken,
Guid assetId,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
Task DeleteAssetArtifactsAsync(
Guid assetId,
string extension,
CancellationToken cancellationToken
);
}
@@ -1,21 +1,21 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
public interface ISiteSettings
{
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
}
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
public interface ISiteSettings
{
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
}
@@ -1,89 +1,89 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.ListShows;
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
{
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
ListShowsQuery query,
CancellationToken cancellationToken
)
{
var source = dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.Include(s => s.Genres)
// Серии и жанры — сиблинги: одним запросом это честное перемножение (сериал на 200 серий
// с тремя жанрами даёт 600 строк вместо 203). Разделяем.
.AsSplitQuery()
.Where(s =>
query.Interstitials
? s.Kind == ShowKind.Interstitial
: s.Kind != ShowKind.Interstitial
);
var filtered = query.GenreId is { } genreId
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
: source;
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
// Названия только для основных жанров — в списке показывается один.
var primaryIds = shows
.Select(s => s.PrimaryGenreId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var genreNames = await dbContext
.Genres.AsNoTracking()
.Where(g => primaryIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
var assetIds = shows
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
.Distinct()
.ToList();
var names = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
return shows
.Select(s =>
{
var seasons = s
.Episodes.Select(e =>
names.TryGetValue(e.MediaAssetId, out var n)
? EpisodeName.ParseSeason(n)
: null
)
.Where(season => season is not null)
.Distinct()
.Count();
return new ShowSummaryDto(
s.Id,
s.Name,
s.OriginalName,
s.Kind,
s.Audience,
s.Episodes.Count,
seasons,
s.Year,
s.PosterImageId is not null,
s.CreatedAt,
s.PrimaryGenreId is { } primaryId
&& genreNames.TryGetValue(primaryId, out var g)
? g
: null
);
})
.ToList();
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.ListShows;
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
{
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
ListShowsQuery query,
CancellationToken cancellationToken
)
{
var source = dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.Include(s => s.Genres)
// Серии и жанры — сиблинги: одним запросом это честное перемножение (сериал на 200 серий
// с тремя жанрами даёт 600 строк вместо 203). Разделяем.
.AsSplitQuery()
.Where(s =>
query.Interstitials
? s.Kind == ShowKind.Interstitial
: s.Kind != ShowKind.Interstitial
);
var filtered = query.GenreId is { } genreId
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
: source;
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
// Названия только для основных жанров — в списке показывается один.
var primaryIds = shows
.Select(s => s.PrimaryGenreId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var genreNames = await dbContext
.Genres.AsNoTracking()
.Where(g => primaryIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
var assetIds = shows
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
.Distinct()
.ToList();
var names = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
return shows
.Select(s =>
{
var seasons = s
.Episodes.Select(e =>
names.TryGetValue(e.MediaAssetId, out var n)
? EpisodeName.ParseSeason(n)
: null
)
.Where(season => season is not null)
.Distinct()
.Count();
return new ShowSummaryDto(
s.Id,
s.Name,
s.OriginalName,
s.Kind,
s.Audience,
s.Episodes.Count,
seasons,
s.Year,
s.PosterImageId is not null,
s.CreatedAt,
s.PrimaryGenreId is { } primaryId
&& genreNames.TryGetValue(primaryId, out var g)
? g
: null
);
})
.ToList();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,237 +1,237 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param>
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken,
DateTimeOffset? historyFrom = null
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
if (groupIds.Count == 0)
return result;
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
var collectionIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.Distinct()
.ToList();
var collectionParts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.OrderBy(i => i.Position)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var showIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.Concat(collectionParts.Select(p => p.ShowId))
.Distinct()
.ToList();
var loaded = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Audience,
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
})
.ToListAsync(cancellationToken);
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
var recentPlays = await LoadRecentPlaysAsync(
channelId,
showIds,
historyFrom,
cancellationToken
);
var data = new ExpansionData(
shows,
durations,
lastPlayed,
recentPlays,
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
);
foreach (var group in groups)
result[group.Id] = group
.Items.OrderBy(i => i.Position)
.Select(item =>
item.ElementKind == GroupElementKind.Show
? data.ShowElement(item)
: data.CollectionElement(item)
)
.ToList();
return result;
}
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
/// <summary>
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
/// В БД отсюда не ходят — всё уже загружено.
/// </summary>
private sealed record ExpansionData(
IReadOnlyDictionary<Guid, ShowRow> Shows,
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
ILookup<Guid, Guid> CollectionParts
)
{
public PlanningElement ShowElement(GroupItem item) =>
new(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOf(item.ElementId),
LastPlayedOf(item.ElementId),
AudienceOf(item.ElementId),
RecentPlaysOf(item.ElementId)
);
/// <summary>
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
/// </summary>
public PlanningElement CollectionElement(GroupItem item)
{
var parts = CollectionParts[item.ElementId].ToList();
return new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
parts.SelectMany(UnitsOf).ToList(),
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
);
}
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
private List<PlanningUnit> UnitsOf(Guid showId) =>
Shows.TryGetValue(showId, out var show)
? show
.AssetIds.Where(Durations.ContainsKey)
.Select(
(assetId, index) =>
new PlanningUnit(assetId, Durations[assetId], showId, index)
)
.ToList()
: [];
private DateTimeOffset? LastPlayedOf(Guid showId) =>
LastPlayed.TryGetValue(showId, out var at) ? at : null;
private ShowAudience? AudienceOf(Guid showId) =>
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
CancellationToken cancellationToken
) =>
await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId.Value)
)
.GroupBy(e => e.ShowId!.Value)
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
/// <summary>
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
/// это лишние сотни строк на каждый прогон.
/// </summary>
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
DateTimeOffset? from,
CancellationToken cancellationToken
)
{
if (from is not { } since || showIds.Count == 0)
return [];
var plays = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId.Value)
&& e.StartsAtUtc >= since
)
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
.ToListAsync(cancellationToken);
return plays
.GroupBy(p => p.ShowId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
);
}
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param>
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken,
DateTimeOffset? historyFrom = null
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
if (groupIds.Count == 0)
return result;
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
var collectionIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.Distinct()
.ToList();
var collectionParts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.OrderBy(i => i.Position)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var showIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.Concat(collectionParts.Select(p => p.ShowId))
.Distinct()
.ToList();
var loaded = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Audience,
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
})
.ToListAsync(cancellationToken);
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
var recentPlays = await LoadRecentPlaysAsync(
channelId,
showIds,
historyFrom,
cancellationToken
);
var data = new ExpansionData(
shows,
durations,
lastPlayed,
recentPlays,
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
);
foreach (var group in groups)
result[group.Id] = group
.Items.OrderBy(i => i.Position)
.Select(item =>
item.ElementKind == GroupElementKind.Show
? data.ShowElement(item)
: data.CollectionElement(item)
)
.ToList();
return result;
}
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
/// <summary>
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
/// В БД отсюда не ходят — всё уже загружено.
/// </summary>
private sealed record ExpansionData(
IReadOnlyDictionary<Guid, ShowRow> Shows,
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
ILookup<Guid, Guid> CollectionParts
)
{
public PlanningElement ShowElement(GroupItem item) =>
new(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOf(item.ElementId),
LastPlayedOf(item.ElementId),
AudienceOf(item.ElementId),
RecentPlaysOf(item.ElementId)
);
/// <summary>
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
/// </summary>
public PlanningElement CollectionElement(GroupItem item)
{
var parts = CollectionParts[item.ElementId].ToList();
return new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
parts.SelectMany(UnitsOf).ToList(),
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
);
}
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
private List<PlanningUnit> UnitsOf(Guid showId) =>
Shows.TryGetValue(showId, out var show)
? show
.AssetIds.Where(Durations.ContainsKey)
.Select(
(assetId, index) =>
new PlanningUnit(assetId, Durations[assetId], showId, index)
)
.ToList()
: [];
private DateTimeOffset? LastPlayedOf(Guid showId) =>
LastPlayed.TryGetValue(showId, out var at) ? at : null;
private ShowAudience? AudienceOf(Guid showId) =>
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
CancellationToken cancellationToken
) =>
await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId.Value)
)
.GroupBy(e => e.ShowId!.Value)
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
/// <summary>
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
/// это лишние сотни строк на каждый прогон.
/// </summary>
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
DateTimeOffset? from,
CancellationToken cancellationToken
)
{
if (from is not { } since || showIds.Count == 0)
return [];
var plays = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId.Value)
&& e.StartsAtUtc >= since
)
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
.ToListAsync(cancellationToken);
return plays
.GroupBy(p => p.ShowId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
);
}
}
@@ -1,39 +1,39 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
@@ -1,152 +1,152 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName = entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var collectionName = entry.CollectionId is { } collectionId
? await dbContext
.Collections.AsNoTracking()
.Where(c => c.Id == collectionId)
.Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
collectionName,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext
.Slots.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
collectionName,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName = entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var collectionName = entry.CollectionId is { } collectionId
? await dbContext
.Collections.AsNoTracking()
.Where(c => c.Id == collectionId)
.Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
collectionName,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext
.Slots.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
collectionName,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
@@ -1,231 +1,231 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
{
public async Task<Result<CopyTemplateResultDto>> Handle(
CopyTemplateCommand command,
CancellationToken cancellationToken
)
{
var target = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
var source = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var (junctionMap, droppedBumperRefs) = CopyJunctions(
sourceJunctions,
target.Id,
sourceBumperNames,
bumperByName
);
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext
.JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
)
.ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
if (
source.DefaultJunctionId is { } defaultJunction
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
)
copyTemplate.SetDefaultJunction(mappedDefault);
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
);
}
/// <summary>
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
/// </summary>
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
IReadOnlyList<JunctionTemplate> sourceJunctions,
Guid targetChannelId,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName
)
{
var map = new Dictionary<Guid, Guid>();
var dropped = 0;
foreach (var junction in sourceJunctions)
{
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
map[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position))
{
var bumperTemplateId = MapBumper(
element,
sourceBumperNames,
targetBumperByName,
ref dropped
);
copy.AddElement(element.Kind)
.Update(
element.Kind,
element.GroupId,
bumperTemplateId,
element.AmountMode,
element.AmountValue,
element.IsRequired,
element.ConditionsJson
);
}
dbContext.JunctionTemplates.Add(copy);
}
return (map, dropped);
}
/// <summary>
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
/// и это попадает в отчёт.
/// </summary>
private static Guid? MapBumper(
JunctionElement element,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName,
ref int dropped
)
{
if (element.Kind != JunctionElementKind.Bumper)
return null;
if (
element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name)
&& targetBumperByName.TryGetValue(name, out var mapped)
)
return mapped;
dropped++;
return null;
}
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source,
ScheduleTemplate copyTemplate,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var layers = 0;
var slots = 0;
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
{
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
var copyLayer = layer.IsBackground
? copyTemplate.Background!
: copyTemplate.AddLayer(layer.Name, layer.Priority);
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
if (!layer.IsBackground)
layers++;
foreach (var slot in layer.Slots)
{
CopySlot(slot, copyLayer, junctionMap);
slots++;
}
}
return (layers, slots);
}
private static void CopySlot(
Slot slot,
GridLayer copyLayer,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.SlotKind,
slot.Weekday
);
copySlot.UpdateTiming(
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes
);
copySlot.UpdateContent(
new SlotContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
)
);
}
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
{
public async Task<Result<CopyTemplateResultDto>> Handle(
CopyTemplateCommand command,
CancellationToken cancellationToken
)
{
var target = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
var source = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var (junctionMap, droppedBumperRefs) = CopyJunctions(
sourceJunctions,
target.Id,
sourceBumperNames,
bumperByName
);
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext
.JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
)
.ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
if (
source.DefaultJunctionId is { } defaultJunction
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
)
copyTemplate.SetDefaultJunction(mappedDefault);
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
);
}
/// <summary>
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
/// </summary>
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
IReadOnlyList<JunctionTemplate> sourceJunctions,
Guid targetChannelId,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName
)
{
var map = new Dictionary<Guid, Guid>();
var dropped = 0;
foreach (var junction in sourceJunctions)
{
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
map[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position))
{
var bumperTemplateId = MapBumper(
element,
sourceBumperNames,
targetBumperByName,
ref dropped
);
copy.AddElement(element.Kind)
.Update(
element.Kind,
element.GroupId,
bumperTemplateId,
element.AmountMode,
element.AmountValue,
element.IsRequired,
element.ConditionsJson
);
}
dbContext.JunctionTemplates.Add(copy);
}
return (map, dropped);
}
/// <summary>
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
/// и это попадает в отчёт.
/// </summary>
private static Guid? MapBumper(
JunctionElement element,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName,
ref int dropped
)
{
if (element.Kind != JunctionElementKind.Bumper)
return null;
if (
element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name)
&& targetBumperByName.TryGetValue(name, out var mapped)
)
return mapped;
dropped++;
return null;
}
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source,
ScheduleTemplate copyTemplate,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var layers = 0;
var slots = 0;
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
{
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
var copyLayer = layer.IsBackground
? copyTemplate.Background!
: copyTemplate.AddLayer(layer.Name, layer.Priority);
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
if (!layer.IsBackground)
layers++;
foreach (var slot in layer.Slots)
{
CopySlot(slot, copyLayer, junctionMap);
slots++;
}
}
return (layers, slots);
}
private static void CopySlot(
Slot slot,
GridLayer copyLayer,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.SlotKind,
slot.Weekday
);
copySlot.UpdateTiming(
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes
);
copySlot.UpdateContent(
new SlotContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
)
);
}
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
@@ -1,102 +1,102 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.GetTemplate;
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
{
public async Task<Result<ScheduleTemplateDto>> Handle(
GetChannelTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
var groupIds = template
.Layers.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var layers = template
.Layers.OrderByDescending(l => l.Priority)
.Select(layer => new GridLayerDto(
layer.Id,
layer.Name,
layer.Priority,
layer.IsEnabled,
layer.IsBackground,
LayerApplicability.FromJson(layer.ApplicabilityJson),
layer
.Slots.OrderBy(s => s.Weekday ?? -1)
.ThenBy(s => s.TargetStart)
.Select(slot => new SlotDto(
slot.Id,
slot.LayerId,
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Title,
slot.Daypart,
slot.SlotKind,
slot.GroupId,
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
SlotStrategy.FromJson(slot.StrategyJson),
RepeatSource.FromJson(slot.RepeatSourceJson),
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetweenId,
slot.JunctionAfterId
))
.ToList()
))
.ToList();
return Result.Success(
new ScheduleTemplateDto(
template.Id,
template.ChannelId,
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
PlanningRules.FromJson(template.RulesJson),
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
channel.UtcOffsetMinutes,
channel.DayStartTime,
layers
)
);
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.GetTemplate;
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
{
public async Task<Result<ScheduleTemplateDto>> Handle(
GetChannelTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
var groupIds = template
.Layers.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var layers = template
.Layers.OrderByDescending(l => l.Priority)
.Select(layer => new GridLayerDto(
layer.Id,
layer.Name,
layer.Priority,
layer.IsEnabled,
layer.IsBackground,
LayerApplicability.FromJson(layer.ApplicabilityJson),
layer
.Slots.OrderBy(s => s.Weekday ?? -1)
.ThenBy(s => s.TargetStart)
.Select(slot => new SlotDto(
slot.Id,
slot.LayerId,
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Title,
slot.Daypart,
slot.SlotKind,
slot.GroupId,
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
SlotStrategy.FromJson(slot.StrategyJson),
RepeatSource.FromJson(slot.RepeatSourceJson),
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetweenId,
slot.JunctionAfterId
))
.ToList()
))
.ToList();
return Result.Success(
new ScheduleTemplateDto(
template.Id,
template.ChannelId,
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
PlanningRules.FromJson(template.RulesJson),
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
channel.UtcOffsetMinutes,
channel.DayStartTime,
layers
)
);
}
}
@@ -1,139 +1,139 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateLayerCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByTemplateAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure<Guid>(TemplateErrors.NotFound);
var layer = template.AddLayer(command.Name, command.Priority);
template.MarkChanged();
return Result.Success(layer.Id);
}
}
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateLayerCommand, Result>
{
public async Task<Result> Handle(
UpdateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
layer.Update(
command.Name,
command.Priority,
command.Applicability?.ToJson(),
command.IsEnabled
);
template.MarkChanged();
return Result.Success();
}
}
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteLayerCommand, Result>
{
public async Task<Result> Handle(
DeleteLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
if (layer.IsBackground)
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
template.RemoveLayer(command.LayerId);
template.MarkChanged();
return Result.Success();
}
}
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateTemplateCommand, Result>
{
public async Task<Result> Handle(
UpdateTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.Id == command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure(TemplateErrors.NotFound);
if (
command.FallbackGroupId is { } groupId
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
)
return Result.Failure(TemplateErrors.GroupNotFound);
template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId);
template.SetRules(command.Rules?.ToJson());
template.MarkChanged();
return Result.Success();
}
}
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
internal static class LayerLoader
{
public static Task<ScheduleTemplate?> ByTemplateAsync(
IAppDbContext dbContext,
Guid templateId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
public static Task<ScheduleTemplate?> ByLayerAsync(
IAppDbContext dbContext,
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateLayerCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByTemplateAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure<Guid>(TemplateErrors.NotFound);
var layer = template.AddLayer(command.Name, command.Priority);
template.MarkChanged();
return Result.Success(layer.Id);
}
}
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateLayerCommand, Result>
{
public async Task<Result> Handle(
UpdateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
layer.Update(
command.Name,
command.Priority,
command.Applicability?.ToJson(),
command.IsEnabled
);
template.MarkChanged();
return Result.Success();
}
}
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteLayerCommand, Result>
{
public async Task<Result> Handle(
DeleteLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
if (layer.IsBackground)
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
template.RemoveLayer(command.LayerId);
template.MarkChanged();
return Result.Success();
}
}
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateTemplateCommand, Result>
{
public async Task<Result> Handle(
UpdateTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.Id == command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure(TemplateErrors.NotFound);
if (
command.FallbackGroupId is { } groupId
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
)
return Result.Failure(TemplateErrors.GroupNotFound);
template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId);
template.SetRules(command.Rules?.ToJson());
template.MarkChanged();
return Result.Success();
}
}
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
internal static class LayerLoader
{
public static Task<ScheduleTemplate?> ByTemplateAsync(
IAppDbContext dbContext,
Guid templateId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
public static Task<ScheduleTemplate?> ByLayerAsync(
IAppDbContext dbContext,
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
}
@@ -1,67 +1,67 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
: ICommand<Result<Guid>>;
public sealed record UpdateLayerCommand(
Guid LayerId,
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
) : ICommand<Result>;
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
public sealed record UpdateTemplateCommand(
Guid TemplateId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
) : ICommand<Result>;
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
{
public CreateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
{
public UpdateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
{
public UpdateTemplateCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
// всё равно ограничена глубиной хранения ленты.
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
.InclusiveBetween(1, 365)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
.InclusiveBetween(1, 1000)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleForEach(x => x.Rules!.MaxAudienceByTime)
.Must(window => window.From != window.To)
.WithMessage("Окно нулевой длины ничего не ограничивает.")
.When(x => x.Rules?.MaxAudienceByTime is not null);
}
}
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
: ICommand<Result<Guid>>;
public sealed record UpdateLayerCommand(
Guid LayerId,
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
) : ICommand<Result>;
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
public sealed record UpdateTemplateCommand(
Guid TemplateId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
) : ICommand<Result>;
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
{
public CreateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
{
public UpdateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
{
public UpdateTemplateCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
// всё равно ограничена глубиной хранения ленты.
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
.InclusiveBetween(1, 365)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
.InclusiveBetween(1, 1000)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleForEach(x => x.Rules!.MaxAudienceByTime)
.Must(window => window.From != window.To)
.WithMessage("Окно нулевой длины ничего не ограничивает.")
.When(x => x.Rules?.MaxAudienceByTime is not null);
}
}
@@ -1,75 +1,75 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
/// </summary>
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
{
public bool Contains(TimeOnly moment) =>
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
}
/// <summary>
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
///
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
/// </summary>
public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null,
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
int? MaxBreakMinutesPerHour = null,
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
int? MaxGenreSharePercent = null,
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
int? MaxFallbackSharePercent = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
public ShowAudience? AudienceAt(TimeOnly moment)
{
if (MaxAudienceByTime is not { Count: > 0 } windows)
return null;
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
// отменяться более широким окном, случайно наложенным сверху.
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
return applicable.Count > 0 ? applicable.Min() : null;
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static PlanningRules? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
public sealed record RepeatLimitRule(int WindowDays, int Max);
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
/// </summary>
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
{
public bool Contains(TimeOnly moment) =>
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
}
/// <summary>
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
///
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
/// </summary>
public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null,
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
int? MaxBreakMinutesPerHour = null,
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
int? MaxGenreSharePercent = null,
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
int? MaxFallbackSharePercent = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
public ShowAudience? AudienceAt(TimeOnly moment)
{
if (MaxAudienceByTime is not { Count: > 0 } windows)
return null;
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
// отменяться более широким окном, случайно наложенным сверху.
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
return applicable.Count > 0 ? applicable.Min() : null;
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static PlanningRules? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
public sealed record RepeatLimitRule(int WindowDays, int Max);
@@ -1,59 +1,59 @@
using FluentValidation;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
public sealed record SlotInput(
string Title,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
/// <summary>Стык между единицами внутри блока.</summary>
Guid? JunctionBetweenId = null,
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
Guid? JunctionAfterId = null
);
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
{
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
public SlotInputValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
RuleFor(x => x.BlockValue).GreaterThan(0);
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
RuleFor(x => x.SnapToMinutes)
.Must(value => value is null || AllowedSnap.Contains(value.Value))
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
RuleFor(x => x.Strategy!.CooldownDays)
.InclusiveBetween(0, 3650)
.When(x => x.Strategy is not null);
RuleFor(x => x.RepeatSource!.DaysAgo)
.InclusiveBetween(1, 365)
.When(x => x.RepeatSource is not null);
RuleFor(x => x.RepeatSource!.DurationMinutes)
.InclusiveBetween(1, 24 * 60)
.When(x => x.RepeatSource is not null);
}
}
using FluentValidation;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
public sealed record SlotInput(
string Title,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
/// <summary>Стык между единицами внутри блока.</summary>
Guid? JunctionBetweenId = null,
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
Guid? JunctionAfterId = null
);
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
{
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
public SlotInputValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
RuleFor(x => x.BlockValue).GreaterThan(0);
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
RuleFor(x => x.SnapToMinutes)
.Must(value => value is null || AllowedSnap.Contains(value.Value))
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
RuleFor(x => x.Strategy!.CooldownDays)
.InclusiveBetween(0, 3650)
.When(x => x.Strategy is not null);
RuleFor(x => x.RepeatSource!.DaysAgo)
.InclusiveBetween(1, 365)
.When(x => x.RepeatSource is not null);
RuleFor(x => x.RepeatSource!.DurationMinutes)
.InclusiveBetween(1, 24 * 60)
.When(x => x.RepeatSource is not null);
}
}
@@ -1,110 +1,110 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
/// им нужны соседние слоты и справочник групп.
/// </summary>
public sealed class SlotWriter(IAppDbContext dbContext)
{
/// <summary>
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
/// созданием.
/// </summary>
public async Task<Result> ApplyAsync(
GridLayer layer,
Slot? slot,
SlotInput input,
CancellationToken cancellationToken
)
{
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
if (
layer.HasOverlap(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
slot?.Id
)
)
return Result.Failure(TemplateErrors.SlotsOverlap);
if (input.SlotKind == SlotKind.Content)
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.GroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
return Result.Failure(TemplateErrors.RepeatSourceRequired);
var target =
slot
?? layer.AddSlot(
input.Title,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.SlotKind,
input.Weekday
);
target.UpdateTiming(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.IsAnchor,
input.MaxDriftMinutes,
input.SnapToMinutes
);
target.UpdateContent(
new SlotContent(
input.Title,
input.SlotKind,
input.GroupId,
input.Strategy?.ToJson(),
input.RepeatSource?.ToJson(),
input.BlockMode,
input.BlockValue,
input.OverflowPolicy,
input.JunctionBetweenId,
input.JunctionAfterId
)
);
return Result.Success();
}
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
/// <summary>Загружает шаблон по слоту.</summary>
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
Guid slotId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
cancellationToken
);
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
/// им нужны соседние слоты и справочник групп.
/// </summary>
public sealed class SlotWriter(IAppDbContext dbContext)
{
/// <summary>
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
/// созданием.
/// </summary>
public async Task<Result> ApplyAsync(
GridLayer layer,
Slot? slot,
SlotInput input,
CancellationToken cancellationToken
)
{
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
if (
layer.HasOverlap(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
slot?.Id
)
)
return Result.Failure(TemplateErrors.SlotsOverlap);
if (input.SlotKind == SlotKind.Content)
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.GroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
return Result.Failure(TemplateErrors.RepeatSourceRequired);
var target =
slot
?? layer.AddSlot(
input.Title,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.SlotKind,
input.Weekday
);
target.UpdateTiming(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.IsAnchor,
input.MaxDriftMinutes,
input.SnapToMinutes
);
target.UpdateContent(
new SlotContent(
input.Title,
input.SlotKind,
input.GroupId,
input.Strategy?.ToJson(),
input.RepeatSource?.ToJson(),
input.BlockMode,
input.BlockValue,
input.OverflowPolicy,
input.JunctionBetweenId,
input.JunctionAfterId
)
);
return Result.Success();
}
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
/// <summary>Загружает шаблон по слоту.</summary>
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
Guid slotId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
cancellationToken
);
}
@@ -1,54 +1,54 @@
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
public sealed record SlotDto(
Guid Id,
Guid LayerId,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
string Title,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
string? GroupName,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
Guid? JunctionBetweenId,
Guid? JunctionAfterId
);
public sealed record GridLayerDto(
Guid Id,
string Name,
int Priority,
bool IsEnabled,
bool IsBackground,
LayerApplicability? Applicability,
IReadOnlyList<SlotDto> Slots
);
public sealed record ScheduleTemplateDto(
Guid Id,
Guid ChannelId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
PlanningRules? Rules,
int Revision,
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes,
TimeOnly DayStartTime,
IReadOnlyList<GridLayerDto> Layers
);
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
public sealed record SlotDto(
Guid Id,
Guid LayerId,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
string Title,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
string? GroupName,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
Guid? JunctionBetweenId,
Guid? JunctionAfterId
);
public sealed record GridLayerDto(
Guid Id,
string Name,
int Priority,
bool IsEnabled,
bool IsBackground,
LayerApplicability? Applicability,
IReadOnlyList<SlotDto> Slots
);
public sealed record ScheduleTemplateDto(
Guid Id,
Guid ChannelId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
PlanningRules? Rules,
int Revision,
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes,
TimeOnly DayStartTime,
IReadOnlyList<GridLayerDto> Layers
);
@@ -1,66 +1,66 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates;
public static class TemplateErrors
{
public static readonly Error NotFound = Error.NotFound(
"Templates.NotFound",
"Шаблон сетки не найден."
);
public static readonly Error LayerNotFound = Error.NotFound(
"Templates.LayerNotFound",
"Слой сетки не найден."
);
public static readonly Error SlotNotFound = Error.NotFound(
"Templates.SlotNotFound",
"Слот не найден."
);
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
"Templates.BackgroundLayerCannotBeDeleted",
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
);
public static readonly Error SlotsOverlap = Error.Conflict(
"Templates.SlotsOverlap",
"Слоты одного слоя пересекаются по времени."
);
public static readonly Error GroupRequired = Error.Validation(
"Templates.GroupRequired",
"Слоту с контентом нужна группа."
);
public static readonly Error GroupNotFound = Error.NotFound(
"Templates.GroupNotFound",
"Группа не найдена."
);
public static readonly Error JunctionNotFound = Error.NotFound(
"Templates.JunctionNotFound",
"Шаблон стыка не найден."
);
public static readonly Error JunctionElementNotFound = Error.NotFound(
"Templates.JunctionElementNotFound",
"Врезка не найдена."
);
public static readonly Error JunctionInUse = Error.Conflict(
"Templates.JunctionInUse",
"Стык используется слотами — сначала отвяжите его."
);
public static readonly Error JunctionGroupRequired = Error.Validation(
"Templates.JunctionGroupRequired",
"Врезке нужна группа, откуда брать ролики."
);
public static readonly Error RepeatSourceRequired = Error.Validation(
"Templates.RepeatSourceRequired",
"Слоту-повтору нужно указать, что повторять."
);
}
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates;
public static class TemplateErrors
{
public static readonly Error NotFound = Error.NotFound(
"Templates.NotFound",
"Шаблон сетки не найден."
);
public static readonly Error LayerNotFound = Error.NotFound(
"Templates.LayerNotFound",
"Слой сетки не найден."
);
public static readonly Error SlotNotFound = Error.NotFound(
"Templates.SlotNotFound",
"Слот не найден."
);
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
"Templates.BackgroundLayerCannotBeDeleted",
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
);
public static readonly Error SlotsOverlap = Error.Conflict(
"Templates.SlotsOverlap",
"Слоты одного слоя пересекаются по времени."
);
public static readonly Error GroupRequired = Error.Validation(
"Templates.GroupRequired",
"Слоту с контентом нужна группа."
);
public static readonly Error GroupNotFound = Error.NotFound(
"Templates.GroupNotFound",
"Группа не найдена."
);
public static readonly Error JunctionNotFound = Error.NotFound(
"Templates.JunctionNotFound",
"Шаблон стыка не найден."
);
public static readonly Error JunctionElementNotFound = Error.NotFound(
"Templates.JunctionElementNotFound",
"Врезка не найдена."
);
public static readonly Error JunctionInUse = Error.Conflict(
"Templates.JunctionInUse",
"Стык используется слотами — сначала отвяжите его."
);
public static readonly Error JunctionGroupRequired = Error.Validation(
"Templates.JunctionGroupRequired",
"Врезке нужна группа, откуда брать ролики."
);
public static readonly Error RepeatSourceRequired = Error.Validation(
"Templates.RepeatSourceRequired",
"Слоту-повтору нужно указать, что повторять."
);
}
@@ -1,328 +1,328 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
private const int MinutesInDay = 24 * 60;
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
ValidateTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
var groupIds = layers
.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken);
var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>();
foreach (var layer in layers)
{
issues.AddRange(FindOverlaps(layer, dayStart));
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
}
issues.AddRange(FindGaps(layers, dayStart));
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
}
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
private static IEnumerable<TemplateIssueDto> CheckSlot(
GridLayer layer,
Slot slot,
IReadOnlyDictionary<Guid, GroupStats> groups,
IReadOnlyDictionary<Guid, ShowAudience> strictest,
PlanningRules? rules
)
{
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupMissing,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
);
yield break;
}
if (group.ItemCount == 0)
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupEmpty,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
);
yield break;
}
var perWeek = OccurrencesPerWeek(slot);
if (group.ItemCount < perWeek)
yield return new TemplateIssueDto(
TemplateIssueKind.GroupTooSmall,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
+ "повторы пойдут чаще, чем раз в неделю."
);
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
// и если это больше состава группы, отсекать будет некого.
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
if (
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
)
yield return new TemplateIssueDto(
TemplateIssueKind.CooldownUnreachable,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
+ $"в группе «{group.Name}»."
);
if (
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
&& strictest.TryGetValue(groupId, out var groupAudience)
&& groupAudience > maxAudience
)
yield return new TemplateIssueDto(
TemplateIssueKind.AudienceConflict,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
);
}
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
/// <summary>
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
{
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
// и «телом» внешнего цикла его читает только компилятор.
for (var i = 0; i < slots.Count; i++)
{
for (var j = i + 1; j < slots.Count; j++)
{
var a = slots[i];
var b = slots[j];
if (!SameDays(a, b))
continue;
var aFrom = OffsetInDay(a.TargetStart, dayStart);
var bFrom = OffsetInDay(b.TargetStart, dayStart);
if (
aFrom < bFrom + b.TargetDurationMinutes
&& bFrom < aFrom + a.TargetDurationMinutes
)
yield return new TemplateIssueDto(
TemplateIssueKind.SlotOverlap,
TemplateIssueSeverity.Warning,
layer.Id,
b.Id,
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
);
}
}
}
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
private static bool SameDays(Slot a, Slot b) =>
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
/// <summary>
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindGaps(
IReadOnlyList<GridLayer> layers,
TimeOnly dayStart
)
{
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var intervals = layers
.SelectMany(l => l.Slots)
.Where(s => s.Weekday is null || s.Weekday == weekday)
.Select(s =>
{
var from = OffsetInDay(s.TargetStart, dayStart);
return (From: from, To: from + s.TargetDurationMinutes);
})
.OrderBy(i => i.From)
.ToList();
var cursor = 0;
foreach (var interval in intervals)
{
if (interval.From > cursor)
yield return Gap(weekday, cursor, interval.From, dayStart);
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
}
if (cursor < MinutesInDay)
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
}
}
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
{
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
var interval =
to - from >= MinutesInDay
? "весь день"
: $"{Clock(from, dayStart)}{Clock(to, dayStart)}";
return new TemplateIssueDto(
TemplateIssueKind.GridGap,
TemplateIssueSeverity.Error,
null,
null,
$"Не покрыто: {day} {interval}."
);
}
private static string Clock(int offsetMinutes, TimeOnly dayStart)
{
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
return $"{minutes / 60:00}:{minutes % 60:00}";
}
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
{
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
return diff >= 0 ? diff : diff + MinutesInDay;
}
/// <summary>
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds,
CancellationToken cancellationToken
)
{
if (groupIds.Count == 0)
return [];
var items = await dbContext
.GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId))
.Select(i => new
{
i.GroupId,
i.ElementKind,
i.ElementId,
})
.ToListAsync(cancellationToken);
var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.ToList();
var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.ToList();
var partsByCollection = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds
.Concat(partsByCollection.Select(p => p.ShowId))
.Distinct()
.ToList();
var audiences = await dbContext
.Shows.AsNoTracking()
.Where(s => neededShowIds.Contains(s.Id))
.Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
var result = new Dictionary<Guid, ShowAudience>();
foreach (var item in items)
{
var candidates =
item.ElementKind == GroupElementKind.Show
? [item.ElementId]
: partsByCollection
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
foreach (var showId in candidates)
{
if (audiences.GetValueOrDefault(showId) is not { } audience)
continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience;
}
}
return result;
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
private const int MinutesInDay = 24 * 60;
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
ValidateTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
var groupIds = layers
.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken);
var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>();
foreach (var layer in layers)
{
issues.AddRange(FindOverlaps(layer, dayStart));
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
}
issues.AddRange(FindGaps(layers, dayStart));
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
}
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
private static IEnumerable<TemplateIssueDto> CheckSlot(
GridLayer layer,
Slot slot,
IReadOnlyDictionary<Guid, GroupStats> groups,
IReadOnlyDictionary<Guid, ShowAudience> strictest,
PlanningRules? rules
)
{
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupMissing,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
);
yield break;
}
if (group.ItemCount == 0)
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupEmpty,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
);
yield break;
}
var perWeek = OccurrencesPerWeek(slot);
if (group.ItemCount < perWeek)
yield return new TemplateIssueDto(
TemplateIssueKind.GroupTooSmall,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
+ "повторы пойдут чаще, чем раз в неделю."
);
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
// и если это больше состава группы, отсекать будет некого.
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
if (
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
)
yield return new TemplateIssueDto(
TemplateIssueKind.CooldownUnreachable,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
+ $"в группе «{group.Name}»."
);
if (
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
&& strictest.TryGetValue(groupId, out var groupAudience)
&& groupAudience > maxAudience
)
yield return new TemplateIssueDto(
TemplateIssueKind.AudienceConflict,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
);
}
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
/// <summary>
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
{
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
// и «телом» внешнего цикла его читает только компилятор.
for (var i = 0; i < slots.Count; i++)
{
for (var j = i + 1; j < slots.Count; j++)
{
var a = slots[i];
var b = slots[j];
if (!SameDays(a, b))
continue;
var aFrom = OffsetInDay(a.TargetStart, dayStart);
var bFrom = OffsetInDay(b.TargetStart, dayStart);
if (
aFrom < bFrom + b.TargetDurationMinutes
&& bFrom < aFrom + a.TargetDurationMinutes
)
yield return new TemplateIssueDto(
TemplateIssueKind.SlotOverlap,
TemplateIssueSeverity.Warning,
layer.Id,
b.Id,
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
);
}
}
}
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
private static bool SameDays(Slot a, Slot b) =>
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
/// <summary>
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindGaps(
IReadOnlyList<GridLayer> layers,
TimeOnly dayStart
)
{
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var intervals = layers
.SelectMany(l => l.Slots)
.Where(s => s.Weekday is null || s.Weekday == weekday)
.Select(s =>
{
var from = OffsetInDay(s.TargetStart, dayStart);
return (From: from, To: from + s.TargetDurationMinutes);
})
.OrderBy(i => i.From)
.ToList();
var cursor = 0;
foreach (var interval in intervals)
{
if (interval.From > cursor)
yield return Gap(weekday, cursor, interval.From, dayStart);
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
}
if (cursor < MinutesInDay)
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
}
}
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
{
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
var interval =
to - from >= MinutesInDay
? "весь день"
: $"{Clock(from, dayStart)}{Clock(to, dayStart)}";
return new TemplateIssueDto(
TemplateIssueKind.GridGap,
TemplateIssueSeverity.Error,
null,
null,
$"Не покрыто: {day} {interval}."
);
}
private static string Clock(int offsetMinutes, TimeOnly dayStart)
{
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
return $"{minutes / 60:00}:{minutes % 60:00}";
}
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
{
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
return diff >= 0 ? diff : diff + MinutesInDay;
}
/// <summary>
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds,
CancellationToken cancellationToken
)
{
if (groupIds.Count == 0)
return [];
var items = await dbContext
.GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId))
.Select(i => new
{
i.GroupId,
i.ElementKind,
i.ElementId,
})
.ToListAsync(cancellationToken);
var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.ToList();
var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.ToList();
var partsByCollection = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds
.Concat(partsByCollection.Select(p => p.ShowId))
.Distinct()
.ToList();
var audiences = await dbContext
.Shows.AsNoTracking()
.Where(s => neededShowIds.Contains(s.Id))
.Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
var result = new Dictionary<Guid, ShowAudience>();
foreach (var item in items)
{
var candidates =
item.ElementKind == GroupElementKind.Show
? [item.ElementId]
: partsByCollection
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
foreach (var showId in candidates)
{
if (audiences.GetValueOrDefault(showId) is not { } audience)
continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience;
}
}
return result;
}
}
@@ -1,19 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Settings.GetSiteSettings;
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
{
public async Task<SiteSettingsDto> Handle(
GetSiteSettingsQuery query,
CancellationToken cancellationToken
)
{
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
}
}
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Settings.GetSiteSettings;
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
{
public async Task<SiteSettingsDto> Handle(
GetSiteSettingsQuery query,
CancellationToken cancellationToken
)
{
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
}
}
@@ -1,16 +1,16 @@
namespace TeleWave.Application.Settings;
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
public static class SettingKeys
{
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
public const string RegistrationEnabled = "registration.enabled";
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
/// Сетка каналов остаётся вторым способом навигации всегда.</summary>
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
}
namespace TeleWave.Application.Settings;
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
public static class SettingKeys
{
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
public const string RegistrationEnabled = "registration.enabled";
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
/// Сетка каналов остаётся вторым способом навигации всегда.</summary>
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
}
@@ -1,9 +1,9 @@
namespace TeleWave.Application.Settings;
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
public sealed record SiteSettingsDto(
bool RegistrationEnabled,
string PreferredAudioLanguages,
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
bool ChannelNumbersEnabled
);
namespace TeleWave.Application.Settings;
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
public sealed record SiteSettingsDto(
bool RegistrationEnabled,
string PreferredAudioLanguages,
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
bool ChannelNumbersEnabled
);
@@ -1,10 +1,10 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed record UpdateSiteSettingsCommand(
bool RegistrationEnabled,
string PreferredAudioLanguages,
bool ChannelNumbersEnabled
) : ICommand<Result>;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed record UpdateSiteSettingsCommand(
bool RegistrationEnabled,
string PreferredAudioLanguages,
bool ChannelNumbersEnabled
) : ICommand<Result>;
@@ -1,34 +1,34 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
: ICommandHandler<UpdateSiteSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateSiteSettingsCommand command,
CancellationToken cancellationToken
)
{
// Сохранение выполняет UnitOfWorkBehavior команды.
await siteSettings.SetRegistrationEnabledAsync(
command.RegistrationEnabled,
cancellationToken
);
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
var languages = string.Join(
',',
(command.PreferredAudioLanguages ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => x.ToLowerInvariant())
);
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
await siteSettings.SetChannelNumbersEnabledAsync(
command.ChannelNumbersEnabled,
cancellationToken
);
return Result.Success();
}
}
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
: ICommandHandler<UpdateSiteSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateSiteSettingsCommand command,
CancellationToken cancellationToken
)
{
// Сохранение выполняет UnitOfWorkBehavior команды.
await siteSettings.SetRegistrationEnabledAsync(
command.RegistrationEnabled,
cancellationToken
);
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
var languages = string.Join(
',',
(command.PreferredAudioLanguages ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => x.ToLowerInvariant())
);
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
await siteSettings.SetChannelNumbersEnabledAsync(
command.ChannelNumbersEnabled,
cancellationToken
);
return Result.Success();
}
}
@@ -1,93 +1,93 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
{
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
ListPublicChannelsQuery query,
CancellationToken cancellationToken
)
{
var channels = await dbContext
.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
// разрешает ничью между ненумерованными.
.OrderBy(c => c.Number == null)
.ThenBy(c => c.Number)
.ThenBy(c => c.Name)
.Select(c => new
{
c.Id,
c.Slug,
c.Name,
c.Number,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength,
})
.ToListAsync(cancellationToken);
if (channels.Count == 0)
return [];
var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList();
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
var currentByChannel = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
channelIds.Contains(e.ChannelId)
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now
&& e.EndsAtUtc > now
&& e.ShowId != null
)
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
.ToListAsync(cancellationToken);
var currentShowByChannel = currentByChannel
.GroupBy(x => x.ChannelId)
.ToDictionary(g => g.Key, g => g.First().ShowId);
var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.PosterImageId,
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
.Select(c =>
{
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
return new PublicChannelDto(
c.Id,
c.Slug,
c.Name,
c.Number,
show is null ? null : showId,
show?.Name,
show?.PosterImageId,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength
);
})
.ToList();
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
{
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
ListPublicChannelsQuery query,
CancellationToken cancellationToken
)
{
var channels = await dbContext
.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
// разрешает ничью между ненумерованными.
.OrderBy(c => c.Number == null)
.ThenBy(c => c.Number)
.ThenBy(c => c.Name)
.Select(c => new
{
c.Id,
c.Slug,
c.Name,
c.Number,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength,
})
.ToListAsync(cancellationToken);
if (channels.Count == 0)
return [];
var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList();
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
var currentByChannel = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
channelIds.Contains(e.ChannelId)
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now
&& e.EndsAtUtc > now
&& e.ShowId != null
)
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
.ToListAsync(cancellationToken);
var currentShowByChannel = currentByChannel
.GroupBy(x => x.ChannelId)
.ToDictionary(g => g.Key, g => g.First().ShowId);
var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.PosterImageId,
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
.Select(c =>
{
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
return new PublicChannelDto(
c.Id,
c.Slug,
c.Name,
c.Number,
show is null ? null : showId,
show?.Name,
show?.PosterImageId,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength
);
})
.ToList();
}
}
@@ -1,42 +1,42 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(
Guid Id,
string Slug,
string Name,
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
int? Number,
Guid? CurrentShowId,
string? CurrentShowName,
Guid? CurrentShowPosterImageId,
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
public sealed record PublicEpgEntryDto(
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
Guid? ShowPosterImageId,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
Guid? EpisodeStillImageId
);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylistDto(
long MediaSequence,
int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments
);
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(
Guid Id,
string Slug,
string Name,
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
int? Number,
Guid? CurrentShowId,
string? CurrentShowName,
Guid? CurrentShowPosterImageId,
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
public sealed record PublicEpgEntryDto(
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
Guid? ShowPosterImageId,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
Guid? EpisodeStillImageId
);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylistDto(
long MediaSequence,
int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments
);