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:
co-authored by
Claude Opus 5
parent
9d1c6d2fc3
commit
0442056367
+126
-126
@@ -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;
|
||||
}
|
||||
|
||||
+18
-18
@@ -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);
|
||||
|
||||
+42
-42
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -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
|
||||
);
|
||||
|
||||
+152
-152
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+231
-231
@@ -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;
|
||||
}
|
||||
|
||||
+102
-102
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+139
-139
@@ -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",
|
||||
"Слоту-повтору нужно указать, что повторять."
|
||||
);
|
||||
}
|
||||
|
||||
+328
-328
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+19
-19
@@ -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
|
||||
);
|
||||
|
||||
+10
-10
@@ -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>;
|
||||
|
||||
+34
-34
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+93
-93
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user