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

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelAd;
public sealed record AddChannelAdCommand(Guid ChannelId, Guid MediaAssetId)
: ICommand<Result<Guid>>;
@@ -1,35 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelAd;
public sealed class AddChannelAdCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddChannelAdCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddChannelAdCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Ads)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var assetExists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == command.MediaAssetId,
cancellationToken
);
if (!assetExists)
return Result.Failure<Guid>(ChannelErrors.AssetNotFound);
if (channel.HasAd(command.MediaAssetId))
return Result.Failure<Guid>(ChannelErrors.AdAlreadyAdded);
var ad = channel.AddAd(command.MediaAssetId);
return Result.Success(ad.Id);
}
}
@@ -1,13 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed record AddChannelShowCommand(
Guid ChannelId,
Guid ShowId,
int Weight,
BlockMode BlockMode,
int BlockValue
) : ICommand<Result<Guid>>;
@@ -1,40 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed class AddChannelShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddChannelShowCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddChannelShowCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var showExists = await dbContext.Shows.AnyAsync(
s => s.Id == command.ShowId,
cancellationToken
);
if (!showExists)
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
if (channel.HasShow(command.ShowId))
return Result.Failure<Guid>(ChannelErrors.ShowAlreadyAdded);
var channelShow = channel.AddShow(
command.ShowId,
command.Weight,
command.BlockMode,
command.BlockValue
);
return Result.Success(channelShow.Id);
}
}
@@ -1,12 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed class AddChannelShowCommandValidator : AbstractValidator<AddChannelShowCommand>
{
public AddChannelShowCommandValidator()
{
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
}
}
@@ -1,123 +1,128 @@
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;
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)
.Include(c => c.Shows)
.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 fontFile =
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
// Фон блока — из общего реестра по id (общий для всех подблоков).
string? backgroundPath = null;
if (template.BackgroundImageId is { } bgId)
{
var bgExt = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == bgId)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
if (bgExt is not null)
backgroundPath = imageStore.ResolvePath(bgId, bgExt);
}
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);
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position))
{
var free = variant.Kind == BumperTextKind.Free;
var spec = new BumperRenderSpec(
aligned,
_bumper.Width,
_bumper.Height,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
fontFile,
free ? "" : variant.NowLabel,
free ? "" : fromName,
free ? "" : variant.NextLabel,
free ? "" : toName,
backgroundPath,
audioPath,
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
null,
free,
variant.Line1,
variant.Line2
);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
}
return Result.Success();
}
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary>
private async Task<(string From, string To)> SampleNamesAsync(
Channel channel,
CancellationToken cancellationToken
)
{
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().Take(2).ToList();
var names =
showIds.Count == 0
? []
: await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => s.Name)
.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)
.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 fontFile =
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
// Фон блока — из общего реестра по id (общий для всех подблоков).
string? backgroundPath = null;
if (template.BackgroundImageId is { } bgId)
{
var bgExt = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == bgId)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
if (bgExt is not null)
backgroundPath = imageStore.ResolvePath(bgId, bgExt);
}
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);
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position))
{
var free = variant.Kind == BumperTextKind.Free;
var spec = new BumperRenderSpec(
aligned,
_bumper.Width,
_bumper.Height,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
fontFile,
free ? "" : variant.NowLabel,
free ? "" : fromName,
free ? "" : variant.NextLabel,
free ? "" : toName,
backgroundPath,
audioPath,
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
null,
free,
variant.Line1,
variant.Line2
);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
}
return Result.Success();
}
/// <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,92 +1,59 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
public sealed record ChannelShowDto(
Guid Id,
Guid ShowId,
string ShowName,
int Weight,
BlockMode BlockMode,
int BlockValue,
bool IsEnabled,
int NextEpisodeIndex,
int PreferredWeightMultiplier,
IReadOnlyList<HourWindowDto> PreferredHours
);
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary>
public sealed record HourWindowDto(int StartHour, int EndHour);
public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight);
public sealed record ProgrammingOverrideDto(
Guid Id,
OverrideMode Mode,
OverrideRecurrence Recurrence,
DateTimeOffset? StartsAtUtc,
DateTimeOffset? EndsAtUtc,
int? DayOfWeek,
int? StartMinute,
int? EndMinute,
IReadOnlyList<OverrideShowDto> Shows
);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
/// <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,
AdInsertion AdInsertion,
int AdsPerBreak,
bool BumpersEnabled,
BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId,
IReadOnlyList<ChannelShowDto> Shows,
IReadOnlyList<ChannelAdDto> Ads,
IReadOnlyList<ProgrammingOverrideDto> Overrides
);
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,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
/// <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
);
@@ -1,83 +1,54 @@
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 DuplicateSlug = Error.Conflict(
"Channels.DuplicateSlug",
"Канал с таким slug уже существует."
);
public static readonly Error ShowAlreadyAdded = Error.Conflict(
"Channels.ShowAlreadyAdded",
"Это шоу уже добавлено в канал."
);
public static readonly Error ShowNotFound = Error.NotFound(
"Channels.ShowNotFound",
"Шоу не найдено в библиотеке."
);
public static readonly Error ChannelShowNotFound = Error.NotFound(
"Channels.ChannelShowNotFound",
"Шоу не найдено в канале."
);
public static readonly Error AdAlreadyAdded = Error.Conflict(
"Channels.AdAlreadyAdded",
"Этот ролик уже в пуле рекламы канала."
);
public static readonly Error AdNotFound = Error.NotFound(
"Channels.AdNotFound",
"Реклама не найдена в пуле канала."
);
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",
"Недопустимый файл заставки (формат или размер)."
);
public static readonly Error OverrideNotFound = Error.NotFound(
"Channels.OverrideNotFound",
"Override не найден."
);
public static readonly Error InvalidOverrideWindow = Error.Validation(
"Channels.InvalidOverrideWindow",
"Окончание override должно быть позже начала."
);
public static readonly Error OverrideNeedsShow = Error.Validation(
"Channels.OverrideNeedsShow",
"Override должен ссылаться хотя бы на одно шоу."
);
}
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",
"Недопустимый файл заставки (формат или размер)."
);
}
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.CreateChannel;
@@ -22,7 +23,13 @@ public sealed class CreateChannelCommandHandler(IAppDbContext dbContext)
return Result.Failure<Guid>(ChannelErrors.DuplicateSlug);
var channel = Channel.Create(command.Name, command.Slug, DateTimeOffset.UtcNow);
// Канал без шаблона вещать не может, поэтому шаблон с фоновым слоем заводится сразу.
var template = ScheduleTemplate.Create(channel.Id, command.Name);
channel.SetTemplate(template.Id);
dbContext.Channels.Add(channel);
dbContext.ScheduleTemplates.Add(template);
return Result.Success(channel.Id);
}
}
@@ -1,17 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed record CreateProgrammingOverrideCommand(
Guid ChannelId,
OverrideMode Mode,
OverrideRecurrence Recurrence,
DateTimeOffset? StartsAtUtc,
DateTimeOffset? EndsAtUtc,
int? DayOfWeek,
int? StartMinute,
int? EndMinute,
IReadOnlyList<OverrideShowInput> Shows
) : ICommand<Result<Guid>>;
@@ -1,74 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateProgrammingOverrideCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateProgrammingOverrideCommand command,
CancellationToken cancellationToken
)
{
var weekly = command.Recurrence == OverrideRecurrence.Weekly;
if (weekly)
{
if (
command.DayOfWeek is not (>= 0 and <= 6)
|| command.StartMinute is not { } sm
|| command.EndMinute is not { } em
|| em <= sm
|| sm < 0
|| em > 1440
)
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
}
else if (
command.StartsAtUtc is not { } start
|| command.EndsAtUtc is not { } end
|| end <= start
)
{
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
}
if (command.Shows.Count == 0)
return Result.Failure<Guid>(ChannelErrors.OverrideNeedsShow);
var channel = await dbContext
.Channels.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var showIds = command.Shows.Select(s => s.ShowId).Distinct().ToList();
var existingCount = await dbContext.Shows.CountAsync(
s => showIds.Contains(s.Id),
cancellationToken
);
if (existingCount != showIds.Count)
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
var ovr = weekly
? channel.AddWeeklyOverride(
command.Mode,
command.DayOfWeek!.Value,
command.StartMinute!.Value,
command.EndMinute!.Value
)
: channel.AddOverride(
command.Mode,
command.StartsAtUtc!.Value,
command.EndsAtUtc!.Value
);
foreach (var show in command.Shows)
ovr.AddShow(show.ShowId, show.Weight);
return Result.Success(ovr.Id);
}
}
@@ -1,14 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed class CreateProgrammingOverrideCommandValidator
: AbstractValidator<CreateProgrammingOverrideCommand>
{
public CreateProgrammingOverrideCommandValidator()
{
RuleFor(x => x.Shows).NotEmpty();
RuleForEach(x => x.Shows)
.ChildRules(s => s.RuleFor(i => i.Weight).InclusiveBetween(1, 1000));
}
}
@@ -1,3 +0,0 @@
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed record OverrideShowInput(Guid ShowId, int Weight);
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.DeleteOverride;
public sealed record DeleteProgrammingOverrideCommand(Guid ChannelId, Guid OverrideId)
: ICommand<Result>;
@@ -1,26 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.DeleteOverride;
public sealed class DeleteProgrammingOverrideCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteProgrammingOverrideCommand, Result>
{
public async Task<Result> Handle(
DeleteProgrammingOverrideCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Overrides)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
return channel.RemoveOverride(command.OverrideId)
? Result.Success()
: Result.Failure(ChannelErrors.OverrideNotFound);
}
}
@@ -13,67 +13,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
CancellationToken cancellationToken
)
{
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.Include(c => c.Ads)
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null)
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
var showIds = channel
.Shows.Select(s => s.ShowId)
.Concat(channel.Overrides.SelectMany(o => o.Shows.Select(s => s.ShowId)))
.Distinct()
.ToList();
var showNames = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId).Distinct().ToList();
var assetNames = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => poolAssetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
string ShowName(Guid id) => showNames.GetValueOrDefault(id, "(удалено)");
var shows = channel
.Shows.Select(s => new ChannelShowDto(
s.Id,
s.ShowId,
ShowName(s.ShowId),
s.Weight,
s.BlockMode,
s.BlockValue,
s.IsEnabled,
s.NextEpisodeIndex,
s.PreferredWeightMultiplier,
s.PreferredHours.OrderBy(h => h.StartHour)
.Select(h => new HourWindowDto(h.StartHour, h.EndHour))
.ToList()
))
.ToList();
var ads = channel
.Ads.OrderBy(a => a.Position)
.Select(a => new ChannelAdDto(
a.Id,
a.MediaAssetId,
assetNames.GetValueOrDefault(a.MediaAssetId),
a.Position
))
.ToList();
var bumperTemplates = channel
.BumperTemplates.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto(
@@ -105,32 +54,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
))
.ToList();
var overrides = channel
.Overrides.OrderBy(o => o.Recurrence)
.ThenBy(o => o.StartsAtUtc)
.ThenBy(o => o.DayOfWeek)
.Select(o => new ProgrammingOverrideDto(
o.Id,
o.Mode,
o.Recurrence,
o.StartsAtUtc,
o.EndsAtUtc,
o.DayOfWeek,
o.StartMinute,
o.EndMinute,
o.Shows.Select(s => new OverrideShowDto(s.ShowId, ShowName(s.ShowId), s.Weight))
.ToList()
))
.ToList();
return Result.Success(
new ChannelDto(
channel.Id,
channel.Name,
channel.Slug,
channel.IsEnabled,
channel.AdInsertion,
channel.AdsPerBreak,
channel.Number,
channel.UtcOffsetMinutes,
channel.DayStartTime,
channel.TemplateId,
channel.BumpersEnabled,
new BumperSettingsDto(
channel.BumperFont,
@@ -140,10 +73,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
channel.BumperEpisodeChangeChance
),
bumperTemplates,
channel.FillerAssetId,
shows,
ads,
overrides
channel.FillerAssetId
)
);
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
public sealed record RegenerateChannelScheduleCommand(Guid ChannelId) : ICommand<Result>;
@@ -1,35 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
public sealed class RegenerateChannelScheduleCommandHandler(
IAppDbContext dbContext,
ScheduleGenerator generator
) : ICommandHandler<RegenerateChannelScheduleCommand, Result>
{
public async Task<Result> Handle(
RegenerateChannelScheduleCommand command,
CancellationToken cancellationToken
)
{
var exists = await dbContext.Channels.AnyAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (!exists)
return Result.Failure(ChannelErrors.NotFound);
// Генератор сам сохраняет изменения (удаление хвоста + новые записи + курсоры).
await generator.GenerateAsync(
command.ChannelId,
DateTimeOffset.UtcNow,
regenerate: true,
cancellationToken
);
return Result.Success();
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelAd;
public sealed record RemoveChannelAdCommand(Guid ChannelId, Guid ChannelAdId) : ICommand<Result>;
@@ -1,26 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelAd;
public sealed class RemoveChannelAdCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveChannelAdCommand, Result>
{
public async Task<Result> Handle(
RemoveChannelAdCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Ads)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
return channel.RemoveAd(command.ChannelAdId)
? Result.Success()
: Result.Failure(ChannelErrors.AdNotFound);
}
}
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelShow;
public sealed record RemoveChannelShowCommand(Guid ChannelId, Guid ChannelShowId)
: ICommand<Result>;
@@ -1,26 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelShow;
public sealed class RemoveChannelShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveChannelShowCommand, Result>
{
public async Task<Result> Handle(
RemoveChannelShowCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
return channel.RemoveShow(command.ChannelShowId)
? Result.Success()
: Result.Failure(ChannelErrors.ChannelShowNotFound);
}
}
@@ -1,206 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Broadcast.Scheduling;
/// <summary>
/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки
/// «из→в→подблок» возвращает id ассета-заставки. Сам ffmpeg НЕ запускает — если готового (или уже
/// рендерящегося) ассета в кэше нет, создаёт <see cref="MediaAsset"/> в статусе Pending, кэш-строку
/// <see cref="BumperAsset"/> (с id канала/блока/подблока для восстановления спеки) и ставит в очередь
/// фонового рендерера. Так тик планировщика не блокируется на ffmpeg, а его транзакция не держится во
/// время рендера. До готовности ассета плейлист отдаёт филлер (см. GetLivePlaylistQueryHandler).
/// </summary>
public sealed class ScheduleBumperResolver(
IAppDbContext dbContext,
IBumperRenderQueue renderQueue,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
)
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id ассета:
/// готового/рендерящегося из кэша либо только что созданного Pending (поставлен в очередь рендера).
/// </summary>
public async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveAsync(
Channel channel,
IReadOnlyList<PlannedEntry> entries,
IReadOnlyDictionary<Guid, string> showNames,
CancellationToken cancellationToken
)
{
var result = new Dictionary<(Guid, Guid, Guid), Guid>();
var variantsById = channel
.BumperTemplates.SelectMany(t => t.Variants.Select(v => (Variant: v, Template: t)))
.ToDictionary(x => x.Variant.Id);
var combos = entries
.Where(e =>
e.Kind == ScheduleEntryKind.Bumper
&& e.FromShowId is not null
&& e.ToShowId is not null
&& e.BumperVariantId is not null
)
.Select(e =>
(
From: e.FromShowId!.Value,
To: e.ToShowId!.Value,
Variant: e.BumperVariantId!.Value
)
)
.Distinct()
.ToList();
if (combos.Count == 0)
return result;
var fromIds = combos.Select(c => c.From).Distinct().ToList();
var toIds = combos.Select(c => c.To).Distinct().ToList();
// Постер шоу-получателя участвует в сигнатуре (как токен id) — грузим только id картинки;
// абсолютный путь для рендера резолвит уже фоновый рендерер.
var showIds = fromIds.Concat(toIds).Distinct().ToList();
var posterImageByShow = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
.ToDictionaryAsync(s => s.Id, s => s.ImageId, cancellationToken);
// Кэш заставок (новейшие первыми) + статусы их ассетов: Ready/Pending/Processing переиспользуем,
// Failed — рендерим заново.
var cached = await dbContext
.BumperAssets.AsNoTracking()
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
.OrderByDescending(b => b.CreatedAt)
.Select(b => new
{
b.FromShowId,
b.ToShowId,
b.Signature,
b.MediaAssetId,
})
.ToListAsync(cancellationToken);
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
var statusById = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => cachedAssetIds.Contains(a.Id))
.Select(a => new { a.Id, a.Status })
.ToDictionaryAsync(a => a.Id, a => a.Status, cancellationToken);
foreach (var combo in combos)
{
if (result.ContainsKey(combo))
continue;
if (!variantsById.TryGetValue(combo.Variant, out var pair))
continue;
var (variant, template) = pair;
var fromName = showNames.GetValueOrDefault(combo.From, "…");
var toName = showNames.GetValueOrDefault(combo.To, "…");
var usePoster = variant.Kind == BumperTextKind.NowNext;
var posterToken =
usePoster && posterImageByShow.TryGetValue(combo.To, out var pid)
? pid.ToString()
: "-";
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
);
var signature = ComputeSignature(
channel,
template,
variant,
fromName,
toName,
aligned,
posterToken
);
// Новейшая кэш-строка с этой сигнатурой (список упорядочен по CreatedAt DESC).
var hit = cached.FirstOrDefault(c =>
c.FromShowId == combo.From && c.ToShowId == combo.To && c.Signature == signature
);
if (
hit is not null
&& statusById.TryGetValue(hit.MediaAssetId, out var status)
&& status != MediaAssetStatus.Failed
)
{
result[combo] = hit.MediaAssetId; // Ready / Pending / Processing — переиспользуем
continue;
}
// Нет кэша либо прошлый рендер провалился — создаём Pending-ассет + кэш-строку, ставим в очередь.
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
dbContext.MediaAssets.Add(asset);
dbContext.BumperAssets.Add(
BumperAsset.Create(
channel.Id,
template.Id,
variant.Id,
combo.From,
combo.To,
signature,
asset.Id
)
);
// Сигнал — лучший случай (строка станет видимой после коммита транзакции планировщика);
// гарантия подхвата — периодический опрос БД фоновым рендерером.
renderQueue.Enqueue(asset.Id);
result[combo] = asset.Id;
}
return result;
}
/// <summary>
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
/// заставка пересобирается. Разделитель — unit separator (U+001F), чтобы поля не слипались.
/// </summary>
private string ComputeSignature(
Channel channel,
BumperTemplate template,
BumperTextVariant variant,
string fromName,
string toName,
int alignedDurationSeconds,
string poster
)
{
var raw = string.Join(
'',
_bumper.TemplateVersion,
_bumper.Width,
_bumper.Height,
alignedDurationSeconds,
channel.BumperFont,
variant.Kind,
variant.NowLabel,
variant.NextLabel,
variant.Line1,
variant.Line2,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
template.Revision,
template.BackgroundImageId?.ToString() ?? "-",
template.AudioExtension ?? "-",
fromName,
toName,
poster
);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(hash);
}
}
@@ -1,304 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Broadcast.Scheduling;
/// <summary>
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
/// Ассеты заставок-переходов резолвит/рендерит <see cref="ScheduleBumperResolver"/> (общий контекст).
/// </summary>
public sealed class ScheduleGenerator(
IAppDbContext dbContext,
IRandomSource random,
ScheduleBumperResolver bumperResolver,
IOptions<SchedulerOptions> options,
IOptions<StreamingOptions> streamingOptions
)
{
private readonly SchedulerOptions _options = options.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
/// </summary>
public async Task<int> GenerateAsync(
Guid channelId,
DateTimeOffset now,
bool regenerate,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.Include(c => c.Ads)
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
if (channel is null || !channel.IsEnabled)
return -1;
// Сериализуем генерацию одного канала: фоновый тик и ручная перегенерация не должны читать
// одну точку продолжения и оба дописывать хвост (иначе дубли/перекрытия записей). Advisory-lock
// держится до коммита/отката транзакции ниже; при сбое рендера заставок весь прогон откатится.
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
await dbContext.AcquireChannelLockAsync(channelId, cancellationToken);
var horizonEnd = now.AddDays(_options.HorizonDays);
// Чистим прошлое сверх окна ретеншна.
var retentionCutoff = now.AddHours(-_options.RetentionHours);
await dbContext
.ScheduleEntries.Where(e => e.ChannelId == channelId && e.EndsAtUtc < retentionCutoff)
.ExecuteDeleteAsync(cancellationToken);
// Точка продолжения: конец последней сохранённой записи (для regenerate — только уже стартовавшей).
var lastEnd = await dbContext
.ScheduleEntries.Where(e =>
e.ChannelId == channelId && (!regenerate || e.StartsAtUtc < now)
)
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
var startTime = lastEnd ?? now;
if (startTime < now)
startTime = now;
if (regenerate)
await dbContext
.ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
.ExecuteDeleteAsync(cancellationToken);
if (startTime >= horizonEnd)
{
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return 0;
}
var showNames = await LoadShowNamesAsync(channel, cancellationToken);
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
var result = SchedulePlanner.Plan(input, random);
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + подблоку).
var bumperAssets = await bumperResolver.ResolveAsync(
channel,
result.Entries,
showNames,
cancellationToken
);
var added = 0;
foreach (var entry in result.Entries)
{
ScheduleEntry? scheduleEntry = entry.Kind switch
{
ScheduleEntryKind.Program => ScheduleEntry.Program(
channel.Id,
entry.MediaAssetId,
entry.StartsAtUtc,
entry.EndsAtUtc,
entry.ShowId!.Value,
entry.EpisodeIndex!.Value
),
ScheduleEntryKind.Ad => ScheduleEntry.Ad(
channel.Id,
entry.MediaAssetId,
entry.StartsAtUtc,
entry.EndsAtUtc
),
ScheduleEntryKind.Bumper => BuildBumperEntry(channel.Id, entry, bumperAssets),
_ => null,
};
if (scheduleEntry is null)
continue;
dbContext.ScheduleEntries.Add(scheduleEntry);
added++;
}
foreach (var channelShow in channel.Shows)
if (result.NextEpisodeIndexByChannelShow.TryGetValue(channelShow.Id, out var idx))
channelShow.SetNextEpisodeIndex(idx);
channel.SetNextAdIndex(result.NextAdIndex);
channel.SetNextBumperIndex(result.NextBumperIndex);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return added;
}
private static ScheduleEntry? BuildBumperEntry(
Guid channelId,
PlannedEntry entry,
IReadOnlyDictionary<(Guid From, Guid To, Guid Variant), Guid> bumperAssets
)
{
// Заставка резолвится по паре шоу + выбранному подблоку (отрендерена/из кэша). Если рендер не
// удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл).
if (
entry.FromShowId is not { } from
|| entry.ToShowId is not { } to
|| entry.BumperVariantId is not { } variant
|| !bumperAssets.TryGetValue((from, to, variant), out var assetId)
)
return null;
return ScheduleEntry.Bumper(
channelId,
assetId,
entry.StartsAtUtc,
entry.EndsAtUtc,
entry.ToShowId,
entry.BumperVariantId
);
}
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
Channel channel,
CancellationToken cancellationToken
)
{
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList();
if (showIds.Count == 0)
return new Dictionary<Guid, string>();
return await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
}
private async Task<PlannerInput> BuildInputAsync(
Channel channel,
DateTimeOffset startTime,
DateTimeOffset horizonEnd,
CancellationToken cancellationToken
)
{
var enabledShows = channel.Shows.Where(s => s.IsEnabled).ToList();
var showIds = enabledShows.Select(s => s.ShowId).Distinct().ToList();
var shows = await dbContext
.Shows.Include(s => s.Episodes)
.Where(s => showIds.Contains(s.Id))
.ToListAsync(cancellationToken);
var episodesByShow = shows.ToDictionary(
s => s.Id,
s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList()
);
var candidateAssetIds = episodesByShow
.Values.SelectMany(x => x)
.Concat(channel.Ads.Select(a => a.MediaAssetId))
.Distinct()
.ToList();
var durations = await dbContext
.MediaAssets.Where(a =>
candidateAssetIds.Contains(a.Id)
&& a.Status == MediaAssetStatus.Ready
&& a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(x => x.Id, x => x.Duration!.Value, cancellationToken);
var plannerShows = new List<PlannerShow>();
foreach (var channelShow in enabledShows)
{
if (!episodesByShow.TryGetValue(channelShow.ShowId, out var episodeIds))
continue;
var ready = episodeIds.Where(durations.ContainsKey).ToList();
if (ready.Count == 0)
continue;
plannerShows.Add(
new PlannerShow(
channelShow.Id,
channelShow.ShowId,
channelShow.Weight,
channelShow.BlockMode,
channelShow.BlockValue,
ready,
channelShow.NextEpisodeIndex,
channelShow
.PreferredHours.Select(h => new PlannerHourWindow(h.StartHour, h.EndHour))
.ToList(),
channelShow.PreferredWeightMultiplier
)
);
}
var adPool = channel
.Ads.OrderBy(a => a.Position)
.Select(a => a.MediaAssetId)
.Where(durations.ContainsKey)
.ToList();
// Подблоки заставок (плоский список): длительность слота — по звуку блока, выровнена на сегмент.
var bumperVariants = channel
.BumperTemplates.OrderBy(t => t.Position)
.SelectMany(t =>
{
var dur = TimeSpan.FromSeconds(
BumperDuration.Aligned(BumperDuration.TemplateSeconds(t), _segmentSeconds)
);
return t
.Variants.OrderBy(v => v.Position)
.Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight));
})
.ToList();
var overrides = channel
.Overrides.Select(o => new PlannerOverride(
o.Mode,
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(),
o.Recurrence,
o.StartsAtUtc,
o.EndsAtUtc,
o.DayOfWeek,
o.StartMinute,
o.EndMinute
))
.ToList();
var bumpers = new PlannerBumperConfig(
channel.BumpersEnabled,
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
channel.BumperSelection,
bumperVariants,
channel.BumperShowChangeChance,
channel.BumperEpisodeChangeChance
);
return new PlannerInput(
channel.Id,
channel.AdInsertion,
channel.AdsPerBreak,
channel.NextAdIndex,
plannerShows,
adPool,
durations,
overrides,
startTime,
horizonEnd,
bumpers,
channel.NextBumperIndex
);
}
}
@@ -1,15 +1,21 @@
namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
/// <summary>На сколько дней вперёд держать материализованное расписание.</summary>
public int HorizonDays { get; init; } = 3;
/// <summary>Сколько часов прошедшего расписания хранить (для EPG «что было»), затем чистить.</summary>
public int RetentionHours { get; init; } = 24;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
/// <summary>
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
/// </summary>
public int HorizonDays { get; init; } = 7;
/// <summary>
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
public int RetentionDays { get; init; } = 90;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
@@ -1,25 +1,23 @@
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,
AdInsertion AdInsertion,
int AdsPerBreak,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
) : ICommand<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
public sealed record BumperSettingsInput(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
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,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
@@ -1,50 +1,48 @@
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.AdInsertion,
command.AdsPerBreak,
command.BumpersEnabled,
command.FillerAssetId
);
channel.UpdateBumperSettings(
command.Bumper.Font,
command.Bumper.MinIntervalMinutes,
command.Bumper.Selection,
command.Bumper.ShowChangeChance,
command.Bumper.EpisodeChangeChance
);
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.MinIntervalMinutes,
command.Bumper.Selection,
command.Bumper.ShowChangeChance,
command.Bumper.EpisodeChangeChance
);
return Result.Success();
}
}
@@ -1,17 +1,16 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand>
{
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10);
RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0);
RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0);
}
}
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand>
{
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0);
RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0);
}
}
@@ -1,19 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
public sealed record UpdateChannelShowCommand(
Guid ChannelId,
Guid ChannelShowId,
int Weight,
BlockMode BlockMode,
int BlockValue,
bool IsEnabled,
int PreferredWeightMultiplier,
IReadOnlyList<HourWindowInput> PreferredHours
) : ICommand<Result>;
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary>
public sealed record HourWindowInput(int StartHour, int EndHour);
@@ -1,39 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
public sealed class UpdateChannelShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelShowCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelShowCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var channelShow = channel.FindShow(command.ChannelShowId);
if (channelShow is null)
return Result.Failure(ChannelErrors.ChannelShowNotFound);
channelShow.Update(
command.Weight,
command.BlockMode,
command.BlockValue,
command.IsEnabled
);
channelShow.SetPreferredHours(
command.PreferredWeightMultiplier,
command.PreferredHours.Select(h => (h.StartHour, h.EndHour))
);
return Result.Success();
}
}
@@ -1,22 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
public sealed class UpdateChannelShowCommandValidator : AbstractValidator<UpdateChannelShowCommand>
{
public UpdateChannelShowCommandValidator()
{
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
RuleFor(x => x.PreferredWeightMultiplier).InclusiveBetween(1, 100);
RuleForEach(x => x.PreferredHours)
.ChildRules(w =>
{
w.RuleFor(h => h.StartHour).InclusiveBetween(0, 23);
w.RuleFor(h => h.EndHour).InclusiveBetween(1, 24);
w.RuleFor(h => h)
.Must(h => h.StartHour < h.EndHour)
.WithMessage("Начало окна должно быть раньше конца.");
});
}
}
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelTime;
/// <summary>
/// Номер канала и его время: смещение от UTC и начало вещательных суток. Отдельно от общих настроек
/// канала, потому что те завязаны на старую ротацию и уйдут вместе с ней.
/// </summary>
public sealed record UpdateChannelTimeCommand(
Guid ChannelId,
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
) : ICommand<Result>;
@@ -0,0 +1,38 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelTime;
public sealed class UpdateChannelTimeCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelTimeCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelTimeCommand 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.Number is { } number)
{
var taken = await dbContext.Channels.AnyAsync(
c => c.Id != channel.Id && c.Number == number,
cancellationToken
);
if (taken)
return Result.Failure(ChannelErrors.NumberTaken);
}
channel.UpdateTimeSettings(command.Number, command.UtcOffsetMinutes, command.DayStartTime);
return Result.Success();
}
}