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.
This commit is contained in:
@@ -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>>;
|
||||
-40
@@ -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);
|
||||
}
|
||||
}
|
||||
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
+128
-123
@@ -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",
|
||||
"Недопустимый файл заставки (формат или размер)."
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-17
@@ -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>>;
|
||||
-74
@@ -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);
|
||||
}
|
||||
}
|
||||
-14
@@ -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);
|
||||
-7
@@ -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>;
|
||||
-26
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
|
||||
|
||||
public sealed record RegenerateChannelScheduleCommand(Guid ChannelId) : ICommand<Result>;
|
||||
-35
@@ -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>;
|
||||
-26
@@ -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);
|
||||
}
|
||||
}
|
||||
-7
@@ -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>;
|
||||
-26
@@ -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;
|
||||
}
|
||||
|
||||
+23
-25
@@ -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
|
||||
);
|
||||
|
||||
+48
-50
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-19
@@ -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);
|
||||
-39
@@ -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();
|
||||
}
|
||||
}
|
||||
-22
@@ -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("Начало окна должно быть раньше конца.");
|
||||
});
|
||||
}
|
||||
}
|
||||
+15
@@ -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>;
|
||||
+38
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user