Implement BumperEndpoints and remove deprecated bumper-related functionality
Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Добавить новый блок заставки на канал (звук/фон загружаются отдельно).</summary>
|
||||
public sealed record AddBumperTemplateCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
|
||||
@@ -1,28 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class AddBumperTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddBumperTemplateCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddBumperTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var name = string.IsNullOrWhiteSpace(command.Name)
|
||||
? $"Заставка {channel.BumperTemplates.Count + 1}"
|
||||
: command.Name.Trim();
|
||||
var template = channel.AddBumperTemplate(name);
|
||||
return Result.Success(template.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Добавить подблок (текст-вариант) в блок заставки.</summary>
|
||||
public sealed record AddBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, string Name)
|
||||
: ICommand<Result<Guid>>;
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class AddBumperTextVariantCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddBumperTextVariantCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddBumperTextVariantCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
||||
if (template is null)
|
||||
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
|
||||
|
||||
var name = string.IsNullOrWhiteSpace(command.Name)
|
||||
? $"Текст {template.Variants.Count + 1}"
|
||||
: command.Name.Trim();
|
||||
var variant = template.AddVariant(name);
|
||||
return Result.Success(variant.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class AddBumperVariantCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddBumperVariantCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddBumperVariantCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure<Guid>(BumperErrors.TemplateNotFound);
|
||||
|
||||
var variant = template.AddVariant(command.Name);
|
||||
variant.SetLines(BumperTemplateLoader.DefaultLines());
|
||||
return Result.Success(variant.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed record ListBumperTemplatesQuery : IQuery<IReadOnlyList<BumperTemplateDto>>;
|
||||
|
||||
public sealed record CreateBumperTemplateCommand(string Name) : ICommand<Result<Guid>>;
|
||||
|
||||
/// <summary>Оформление блока: имя, шрифт и палитра. Меняет ревизию — заставки пересобираются.</summary>
|
||||
public sealed record UpdateBumperTemplateCommand(Guid TemplateId, BumperStyle Style)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed record DeleteBumperTemplateCommand(Guid TemplateId) : ICommand<Result>;
|
||||
|
||||
public sealed record SetBumperTemplateAudioCommand(
|
||||
Guid TemplateId,
|
||||
string Extension,
|
||||
double DurationSeconds
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed record ClearBumperTemplateAudioCommand(Guid TemplateId) : ICommand<Result>;
|
||||
|
||||
public sealed record SetBumperTemplateBackgroundCommand(Guid TemplateId, Guid ImageId)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed record ClearBumperTemplateBackgroundCommand(Guid TemplateId) : ICommand<Result>;
|
||||
|
||||
public sealed record AddBumperVariantCommand(Guid TemplateId, string Name) : ICommand<Result<Guid>>;
|
||||
|
||||
/// <summary>Полное содержимое подблока — строки редактор всегда присылает списком целиком.</summary>
|
||||
public sealed record BumperVariantInput(
|
||||
string Name,
|
||||
BumperTrigger Trigger,
|
||||
BumperBackground Background,
|
||||
int Weight,
|
||||
IReadOnlyList<BumperLineDto> Lines
|
||||
);
|
||||
|
||||
public sealed record UpdateBumperVariantCommand(
|
||||
Guid TemplateId,
|
||||
Guid VariantId,
|
||||
BumperVariantInput Input
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed record RemoveBumperVariantCommand(Guid TemplateId, Guid VariantId) : ICommand<Result>;
|
||||
|
||||
/// <summary>
|
||||
/// Рендер примера заставки. Канал нужен только для образцов подстановки: блок общий, но посмотреть
|
||||
/// его надо глазами конкретного канала — иначе <c>{channel}</c> не на что заменить.
|
||||
/// </summary>
|
||||
public sealed record RenderBumperPreviewCommand(Guid TemplateId, Guid? ChannelId)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed class CreateBumperTemplateCommandValidator
|
||||
: AbstractValidator<CreateBumperTemplateCommand>
|
||||
{
|
||||
public CreateBumperTemplateCommandValidator() =>
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||
}
|
||||
|
||||
public sealed class UpdateBumperTemplateCommandValidator
|
||||
: AbstractValidator<UpdateBumperTemplateCommand>
|
||||
{
|
||||
public UpdateBumperTemplateCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Style.Name).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Style.BackgroundColor).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.Style.BackgroundColor2).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.Style.AccentColor).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.Style.TextColor).NotEmpty().MaximumLength(32);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AddBumperVariantCommandValidator : AbstractValidator<AddBumperVariantCommand>
|
||||
{
|
||||
public AddBumperVariantCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||
}
|
||||
|
||||
public sealed class UpdateBumperVariantCommandValidator
|
||||
: AbstractValidator<UpdateBumperVariantCommand>
|
||||
{
|
||||
/// <summary>Больше шести строк в кадр не помещается ни при каком размере шрифта.</summary>
|
||||
private const int MaxLines = 6;
|
||||
|
||||
public UpdateBumperVariantCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Input.Name).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Input.Weight).InclusiveBetween(0, 1000);
|
||||
RuleFor(x => x.Input.Lines).NotNull().Must(l => l.Count <= MaxLines);
|
||||
RuleForEach(x => x.Input.Lines)
|
||||
.ChildRules(line => line.RuleFor(l => l.Text).NotEmpty().MaximumLength(120));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами.</summary>
|
||||
public sealed record BumperLineDto(BumperLineStyle Style, BumperLineColor Color, string Text);
|
||||
|
||||
/// <summary>Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока.</summary>
|
||||
public sealed record BumperTextVariantDto(
|
||||
Guid Id,
|
||||
int Position,
|
||||
string Name,
|
||||
BumperTrigger Trigger,
|
||||
BumperBackground Background,
|
||||
int Weight,
|
||||
IReadOnlyList<BumperLineDto> Lines
|
||||
);
|
||||
|
||||
/// <summary>Блок заставки: оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
||||
public sealed record BumperTemplateDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
BumperFont Font,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
Guid? BackgroundImageId,
|
||||
bool HasAudio,
|
||||
double? AudioDurationSeconds,
|
||||
/// <summary>Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки.</summary>
|
||||
int UsageCount,
|
||||
IReadOnlyList<BumperTextVariantDto> Variants
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Ошибки блоков заставок. Блоки общие для всех каналов, поэтому и каталог свой, не канальный.</summary>
|
||||
public static class BumperErrors
|
||||
{
|
||||
public static readonly Error TemplateNotFound = Error.NotFound(
|
||||
"Bumpers.TemplateNotFound",
|
||||
"Блок заставки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error VariantNotFound = Error.NotFound(
|
||||
"Bumpers.VariantNotFound",
|
||||
"Подблок заставки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error CannotRemoveLastVariant = Error.Validation(
|
||||
"Bumpers.CannotRemoveLastVariant",
|
||||
"Нельзя удалить последний подблок — нужен хотя бы один."
|
||||
);
|
||||
|
||||
public static readonly Error TemplateInUse = Error.Conflict(
|
||||
"Bumpers.TemplateInUse",
|
||||
"Блок заставки используется во врезках стыков."
|
||||
);
|
||||
|
||||
public static readonly Error VariantInUse = Error.Conflict(
|
||||
"Bumpers.VariantInUse",
|
||||
"Подблок заставки выбран во врезке стыка."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidFile = Error.Validation(
|
||||
"Bumpers.InvalidFile",
|
||||
"Недопустимый файл заставки (формат или размер)."
|
||||
);
|
||||
|
||||
/// <summary>Неизвестный плейсхолдер — ошибка ввода: в эфире его бы уже никто не заметил.</summary>
|
||||
public static Error UnknownPlaceholders(IEnumerable<string> tokens) =>
|
||||
Error.Validation(
|
||||
"Bumpers.UnknownPlaceholders",
|
||||
$"Неизвестные плейсхолдеры: {string.Join(", ", tokens.Select(t => $"{{{t}}}"))}."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Ручной маппинг блока заставки в DTO — одна точка на список и на карточку.</summary>
|
||||
public static class BumperMapper
|
||||
{
|
||||
public static BumperTemplateDto ToDto(BumperTemplate template, int usageCount) =>
|
||||
new(
|
||||
template.Id,
|
||||
template.Name,
|
||||
template.Font,
|
||||
template.BackgroundColor,
|
||||
template.BackgroundColor2,
|
||||
template.AccentColor,
|
||||
template.TextColor,
|
||||
template.BackgroundImageId,
|
||||
template.AudioExtension is not null,
|
||||
template.AudioDurationSeconds,
|
||||
usageCount,
|
||||
template
|
||||
.Variants.OrderBy(v => v.Position)
|
||||
.Select(v => new BumperTextVariantDto(
|
||||
v.Id,
|
||||
v.Position,
|
||||
v.Name,
|
||||
v.Trigger,
|
||||
v.Background,
|
||||
v.Weight,
|
||||
v.Lines.OrderBy(l => l.Position)
|
||||
.Select(l => new BumperLineDto(l.Style, l.Color, l.Text))
|
||||
.ToList()
|
||||
))
|
||||
.ToList()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Чем подставляются плейсхолдеры одной заставки. Собирает контекст планировщик — только он знает
|
||||
/// и пару соседей, и точное время показа; редактор подставляет те же поля образцами.
|
||||
/// </summary>
|
||||
public sealed record BumperContext(
|
||||
string ChannelName,
|
||||
int? ChannelNumber,
|
||||
/// <summary>Момент показа заставки во времени канала.</summary>
|
||||
DateTimeOffset LocalMoment,
|
||||
string? NowTitle = null,
|
||||
string? NextTitle = null,
|
||||
string? NowEpisode = null,
|
||||
string? NextEpisode = null,
|
||||
int? NextYear = null,
|
||||
string? NextGenre = null,
|
||||
/// <summary>Во сколько начнётся следующая программа (время канала).</summary>
|
||||
TimeOnly? NextTime = null,
|
||||
string? SlotTitle = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30».
|
||||
///
|
||||
/// Список закрытый и проверяется при сохранении: незнакомый плейсхолдер — ошибка ввода, а не
|
||||
/// сюрприз в эфире, где его уже не увидит никто, кроме зрителя.
|
||||
/// </summary>
|
||||
public static partial class BumperPlaceholders
|
||||
{
|
||||
/// <summary>Все допустимые имена. Описания и образцы живут в локалях редактора, не здесь.</summary>
|
||||
public static readonly IReadOnlySet<string> Tokens = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"channel",
|
||||
"channel.number",
|
||||
"now.title",
|
||||
"next.title",
|
||||
"now.episode",
|
||||
"next.episode",
|
||||
"next.year",
|
||||
"next.genre",
|
||||
"next.time",
|
||||
"time",
|
||||
"date",
|
||||
"weekday",
|
||||
"slot",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Плейсхолдеры, привязанные к моменту показа. Каждое их значение уникально, поэтому кэш
|
||||
/// отрендеренных заставок с ними перестаёт работать — редактор обязан об этом предупредить.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlySet<string> VolatileTokens = new HashSet<string>(
|
||||
StringComparer.Ordinal
|
||||
)
|
||||
{
|
||||
"time",
|
||||
"date",
|
||||
"weekday",
|
||||
};
|
||||
|
||||
// Русская культура фиксирована: заставка рендерится один раз в видео, локали зрителя у неё нет.
|
||||
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
|
||||
|
||||
[GeneratedRegex(@"\{([a-zA-Z][a-zA-Z.]*)\}", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex TokenPattern();
|
||||
|
||||
[GeneratedRegex(@"[ \t]{2,}", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ExtraSpaces();
|
||||
|
||||
/// <summary>
|
||||
/// Подставляет значения. Неизвестное значение даёт пустую строку: «ДАЛЕЕ В {next.time}» без
|
||||
/// следующей программы должно схлопнуться в «ДАЛЕЕ», а не показать дыру в кадре.
|
||||
/// </summary>
|
||||
public static string Resolve(string text, BumperContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
var resolved = TokenPattern()
|
||||
.Replace(text, match => Value(match.Groups[1].Value, context) ?? string.Empty);
|
||||
return ExtraSpaces().Replace(resolved, " ").Trim();
|
||||
}
|
||||
|
||||
/// <summary>Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.</summary>
|
||||
public static IReadOnlySet<string> TokensIn(IEnumerable<string> texts)
|
||||
{
|
||||
var used = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var text in texts.Where(t => !string.IsNullOrWhiteSpace(t)))
|
||||
foreach (Match match in TokenPattern().Matches(text))
|
||||
used.Add(match.Groups[1].Value);
|
||||
return used;
|
||||
}
|
||||
|
||||
/// <summary>Плейсхолдеры текста, которых нет в списке допустимых.</summary>
|
||||
public static IReadOnlyList<string> UnknownTokens(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return [];
|
||||
|
||||
return TokenPattern()
|
||||
.Matches(text)
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Where(token => !Tokens.Contains(token))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Есть ли в тексте плейсхолдер, привязанный к моменту показа (ломает кэш рендера).</summary>
|
||||
public static bool IsVolatile(string? text) =>
|
||||
!string.IsNullOrWhiteSpace(text)
|
||||
&& TokenPattern().Matches(text).Any(m => VolatileTokens.Contains(m.Groups[1].Value));
|
||||
|
||||
private static string? Value(string token, BumperContext context) =>
|
||||
token switch
|
||||
{
|
||||
"channel" => context.ChannelName,
|
||||
"channel.number" => context.ChannelNumber?.ToString(Culture),
|
||||
"now.title" => context.NowTitle,
|
||||
"next.title" => context.NextTitle,
|
||||
"now.episode" => context.NowEpisode,
|
||||
"next.episode" => context.NextEpisode,
|
||||
"next.year" => context.NextYear?.ToString(Culture),
|
||||
"next.genre" => context.NextGenre,
|
||||
"next.time" => context.NextTime?.ToString("HH:mm", Culture),
|
||||
"time" => TimeOnly
|
||||
.FromDateTime(context.LocalMoment.DateTime)
|
||||
.ToString("HH:mm", Culture),
|
||||
"date" => context.LocalMoment.ToString("d MMMM", Culture),
|
||||
"weekday" => context.LocalMoment.ToString("dddd", Culture),
|
||||
"slot" => context.SlotTitle,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Подпись серии в эфирном виде: «с5э12», либо просто номер, если сезон не распознан.</summary>
|
||||
public static string? Episode(int? season, int? episode)
|
||||
{
|
||||
if (season is { } s and > 0 && episode is { } e and > 0)
|
||||
return string.Create(Culture, $"с{s}э{e}");
|
||||
return episode is { } only and > 0 ? string.Create(Culture, $"э{only}") : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Сериализация готовых строк заставки в кэш-запись. Хранить подставленный текст обязательно:
|
||||
/// время показа и пара соседей из ссылок задним числом не восстанавливаются, а ffmpeg запускается
|
||||
/// фоновым сервисом уже после того, как лента записана.
|
||||
/// </summary>
|
||||
public static class BumperRenderedText
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public static string ToJson(IReadOnlyList<BumperRenderLine> lines) =>
|
||||
JsonSerializer.Serialize(lines, Options);
|
||||
|
||||
public static IReadOnlyList<BumperRenderLine> FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<BumperRenderLine>>(json, Options) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,18 @@ using TeleWave.Domain.Broadcast;
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (пути к постеру/фону/звуку,
|
||||
/// названия шоу). Общая точка для фонового рендерера заставок расписания и превью в админке.
|
||||
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (готовые строки, пути к
|
||||
/// постеру/фону/звуку). Общая точка для фонового рендерера заставок расписания и превью в админке.
|
||||
/// </summary>
|
||||
public static class BumperSpecFactory
|
||||
{
|
||||
public static BumperRenderSpec Build(
|
||||
BumperOptions bumper,
|
||||
BumperFont font,
|
||||
BumperTemplate template,
|
||||
BumperTextVariant variant,
|
||||
int alignedDurationSeconds,
|
||||
BumperSpecInputs inputs
|
||||
)
|
||||
{
|
||||
var free = variant.Kind == BumperTextKind.Free;
|
||||
return new BumperRenderSpec(
|
||||
) =>
|
||||
new(
|
||||
alignedDurationSeconds,
|
||||
bumper.Width,
|
||||
bumper.Height,
|
||||
@@ -27,17 +23,10 @@ public static class BumperSpecFactory
|
||||
template.BackgroundColor2,
|
||||
template.AccentColor,
|
||||
template.TextColor,
|
||||
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
|
||||
free ? "" : variant.NowLabel,
|
||||
free ? "" : inputs.FromName,
|
||||
free ? "" : variant.NextLabel,
|
||||
free ? "" : inputs.ToName,
|
||||
template.Font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
|
||||
inputs.Lines,
|
||||
inputs.BackgroundAbsolutePath,
|
||||
inputs.AudioPath,
|
||||
inputs.PosterAbsolutePath,
|
||||
free,
|
||||
variant.Line1,
|
||||
variant.Line2
|
||||
inputs.PosterAbsolutePath
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их
|
||||
/// вызывающий (генератор эфира — по реальной паре соседей, превью — по образцам канала), а
|
||||
/// <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
|
||||
/// Уже разрешённые входы рендера заставки: готовые строки (плейсхолдеры подставлены) и пути к
|
||||
/// файлам. Разрешает их вызывающий — генератор эфира по реальной паре соседей, редактор по
|
||||
/// образцам, — а <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
|
||||
/// </summary>
|
||||
public sealed record BumperSpecInputs(
|
||||
string FromName,
|
||||
string ToName,
|
||||
IReadOnlyList<BumperRenderLine> Lines,
|
||||
string? AudioPath = null,
|
||||
/// <summary>Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно.</summary>
|
||||
/// <summary>Постер шоу как фон; подставляется, только если подблок его запросил.</summary>
|
||||
string? PosterAbsolutePath = null,
|
||||
string? BackgroundAbsolutePath = null
|
||||
);
|
||||
|
||||
@@ -8,10 +8,9 @@ using TeleWave.Domain.Broadcast;
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил только
|
||||
/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку,
|
||||
/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения,
|
||||
/// воркер лишь крутит ffmpeg.
|
||||
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил
|
||||
/// готовые строки и ссылку на блок, а рендеру нужны ещё абсолютные пути к звуку, постеру и фону.
|
||||
/// Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, воркер лишь крутит ffmpeg.
|
||||
/// </summary>
|
||||
public sealed class BumperSpecLoader(
|
||||
IAppDbContext dbContext,
|
||||
@@ -38,28 +37,15 @@ public sealed class BumperSpecLoader(
|
||||
if (cache is null)
|
||||
return null;
|
||||
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
|
||||
var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
|
||||
var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
|
||||
if (channel is null || template is null || variant is null)
|
||||
var template = await dbContext
|
||||
.BumperTemplates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == cache.TemplateId, cancellationToken);
|
||||
if (template is null)
|
||||
return null;
|
||||
|
||||
var names = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
|
||||
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
|
||||
string? posterPath = null;
|
||||
if (variant.Kind == BumperTextKind.NowNext)
|
||||
posterPath = await ResolveShowPosterAsync(cache.ToShowId, cancellationToken);
|
||||
|
||||
var posterPath = cache.PosterShowId is { } showId
|
||||
? await ResolveShowPosterAsync(showId, cancellationToken)
|
||||
: null;
|
||||
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
|
||||
var aligned = BumperDuration.Aligned(
|
||||
BumperDuration.TemplateSeconds(template),
|
||||
@@ -68,13 +54,10 @@ public sealed class BumperSpecLoader(
|
||||
|
||||
return BumperSpecFactory.Build(
|
||||
_bumper,
|
||||
channel.BumperFont,
|
||||
template,
|
||||
variant,
|
||||
aligned,
|
||||
new BumperSpecInputs(
|
||||
names.GetValueOrDefault(cache.FromShowId, "…"),
|
||||
names.GetValueOrDefault(cache.ToShowId, "…"),
|
||||
BumperRenderedText.FromJson(cache.RenderedLinesJson),
|
||||
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
||||
posterPath,
|
||||
bgPath
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Общее для команд блока заставки: загрузка вместе с подблоками и строками.</summary>
|
||||
internal static class BumperTemplateLoader
|
||||
{
|
||||
public static Task<BumperTemplate?> LoadAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid templateId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.BumperTemplates.Include(t => t.Variants)
|
||||
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
|
||||
|
||||
/// <summary>Строки подблока «Сейчас / Далее» — с них начинается новый блок.</summary>
|
||||
public static IReadOnlyList<BumperLine> DefaultLines() =>
|
||||
[
|
||||
BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"),
|
||||
BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{now.title}"),
|
||||
BumperLine.Create(2, BumperLineStyle.Label, BumperLineColor.Accent, "ДАЛЕЕ"),
|
||||
BumperLine.Create(3, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"),
|
||||
];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Удалить загруженный звук блока (вернуться к синтезированному джинглу).</summary>
|
||||
public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId)
|
||||
: ICommand<Result>;
|
||||
+7
-11
@@ -1,5 +1,4 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
@@ -15,19 +14,16 @@ public sealed class ClearBumperTemplateAudioCommandHandler(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
template.ClearAudio();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await storage.DeleteAudioAsync(command.TemplateId, cancellationToken);
|
||||
await storage.DeleteAudioAsync(template.Id, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Удалить загруженную фон-картинку блока (вернуться к градиенту/постеру).</summary>
|
||||
public sealed record ClearBumperTemplateBackgroundCommand(Guid ChannelId, Guid TemplateId)
|
||||
: ICommand<Result>;
|
||||
+6
-10
@@ -1,5 +1,4 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
@@ -13,17 +12,14 @@ public sealed class ClearBumperTemplateBackgroundCommandHandler(IAppDbContext db
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
// Отвязываем фон; сама картинка остаётся в галерее.
|
||||
template.ClearBackgroundImage();
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class CreateBumperTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateBumperTemplateCommand, Result<Guid>>
|
||||
{
|
||||
private const string DefaultVariantName = "Текст 1";
|
||||
|
||||
public Task<Result<Guid>> Handle(
|
||||
CreateBumperTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = BumperTemplate.Create(command.Name, DefaultVariantName);
|
||||
// Пустой блок в редакторе выглядит поломанным — новый начинается с «Сейчас / Далее».
|
||||
template.Variants[0].SetLines(BumperTemplateLoader.DefaultLines());
|
||||
dbContext.BumperTemplates.Add(template);
|
||||
return Task.FromResult(Result.Success(template.Id));
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class DeleteBumperTemplateCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperTemplateStorage storage
|
||||
) : ICommandHandler<DeleteBumperTemplateCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteBumperTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
// Блок общий: удалив используемый, мы бы молча выключили заставки в чужих каналах.
|
||||
var used = await dbContext.JunctionElements.AnyAsync(
|
||||
e => e.BumperTemplateId == template.Id,
|
||||
cancellationToken
|
||||
);
|
||||
if (used)
|
||||
return Result.Failure(BumperErrors.TemplateInUse);
|
||||
|
||||
dbContext.BumperTemplates.Remove(template);
|
||||
await storage.DeleteAudioAsync(template.Id, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class ListBumperTemplatesQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListBumperTemplatesQuery, IReadOnlyList<BumperTemplateDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<BumperTemplateDto>> Handle(
|
||||
ListBumperTemplatesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var templates = await dbContext
|
||||
.BumperTemplates.AsNoTracking()
|
||||
.Include(t => t.Variants)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Блоки общие, поэтому «сколько врезок на меня ссылается» — не справка, а условие правки.
|
||||
var usage = await dbContext
|
||||
.JunctionElements.AsNoTracking()
|
||||
.Where(e => e.Kind == JunctionElementKind.Bumper && e.BumperTemplateId != null)
|
||||
.GroupBy(e => e.BumperTemplateId!.Value)
|
||||
.Select(g => new { TemplateId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.TemplateId, x => x.Count, cancellationToken);
|
||||
|
||||
return templates.Select(t => BumperMapper.ToDto(t, usage.GetValueOrDefault(t.Id))).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Удалить блок заставки (кроме дефолтного) и его файлы.</summary>
|
||||
public sealed record RemoveBumperTemplateCommand(Guid ChannelId, Guid TemplateId)
|
||||
: ICommand<Result>;
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class RemoveBumperTemplateCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperTemplateStorage storage
|
||||
) : ICommandHandler<RemoveBumperTemplateCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveBumperTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
if (template.IsDefault)
|
||||
return Result.Failure(ChannelErrors.CannotRemoveDefaultBumperTemplate);
|
||||
|
||||
channel.RemoveBumperTemplate(command.TemplateId);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await storage.DeleteTemplateAsync(command.TemplateId, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Удалить подблок (кроме последнего) из блока заставки.</summary>
|
||||
public sealed record RemoveBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, Guid VariantId)
|
||||
: ICommand<Result>;
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class RemoveBumperTextVariantCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveBumperTextVariantCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveBumperTextVariantCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
if (template.FindVariant(command.VariantId) is null)
|
||||
return Result.Failure(ChannelErrors.BumperTextVariantNotFound);
|
||||
|
||||
return template.RemoveVariant(command.VariantId)
|
||||
? Result.Success()
|
||||
: Result.Failure(ChannelErrors.CannotRemoveLastBumperTextVariant);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class RemoveBumperVariantCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveBumperVariantCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveBumperVariantCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
if (template.FindVariant(command.VariantId) is null)
|
||||
return Result.Failure(BumperErrors.VariantNotFound);
|
||||
|
||||
// Врезка могла выбрать этот подблок жёстко — тогда удаление оставило бы её без текста.
|
||||
var used = await dbContext.JunctionElements.AnyAsync(
|
||||
e => e.BumperVariantId == command.VariantId,
|
||||
cancellationToken
|
||||
);
|
||||
if (used)
|
||||
return Result.Failure(BumperErrors.VariantInUse);
|
||||
|
||||
return template.RemoveVariant(command.VariantId)
|
||||
? Result.Success()
|
||||
: Result.Failure(BumperErrors.CannotRemoveLastVariant);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок —
|
||||
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет, но пишет артефакты на диск —
|
||||
/// поэтому это команда (действие с побочным эффектом), а не запрос.
|
||||
/// </summary>
|
||||
public sealed record RenderBumperPreviewCommand(Guid ChannelId, Guid TemplateId) : ICommand<Result>;
|
||||
+125
-65
@@ -1,6 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Streaming;
|
||||
@@ -9,6 +10,11 @@ using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Рендерит пример каждого подблока. Блок общий, поэтому образцы подстановки берутся глазами
|
||||
/// выбранного канала: без него <c>{channel}</c> не на что заменить, а названия шоу были бы
|
||||
/// случайными из библиотеки.
|
||||
/// </summary>
|
||||
public sealed class RenderBumperPreviewCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperRenderer renderer,
|
||||
@@ -21,56 +27,59 @@ public sealed class RenderBumperPreviewCommandHandler(
|
||||
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,
|
||||
RenderBumperPreviewCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
|
||||
var template = await dbContext
|
||||
.BumperTemplates.AsNoTracking()
|
||||
.Include(t => t.Variants)
|
||||
.FirstOrDefaultAsync(t => t.Id == command.TemplateId, cancellationToken);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
||||
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
|
||||
var seconds = template.AudioDurationSeconds is { } d and > 0
|
||||
? d
|
||||
: DefaultBumperDurationSeconds;
|
||||
var aligned = (int)(
|
||||
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
||||
var channel = command.ChannelId is { } channelId
|
||||
? await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken)
|
||||
: null;
|
||||
|
||||
var samples = await SampleShowsAsync(channel, cancellationToken);
|
||||
var context = BuildContext(channel, samples);
|
||||
var backgroundPath = await ResolveImagePathAsync(
|
||||
template.BackgroundImageId,
|
||||
cancellationToken
|
||||
);
|
||||
var posterPath = await ResolveImagePathAsync(samples.NextPosterId, cancellationToken);
|
||||
var aligned = BumperDuration.Aligned(
|
||||
BumperDuration.TemplateSeconds(template),
|
||||
_segmentSeconds
|
||||
);
|
||||
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
||||
|
||||
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
|
||||
var inputs = new BumperSpecInputs(
|
||||
fromName,
|
||||
toName,
|
||||
audioPath,
|
||||
PosterAbsolutePath: null,
|
||||
backgroundPath
|
||||
);
|
||||
|
||||
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
||||
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
||||
{
|
||||
var lines = variant
|
||||
.Lines.OrderBy(l => l.Position)
|
||||
.Select(l => new BumperRenderLine(
|
||||
l.Style,
|
||||
l.Color,
|
||||
BumperPlaceholders.Resolve(l.Text, context)
|
||||
))
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l.Text))
|
||||
.ToList();
|
||||
|
||||
var spec = BumperSpecFactory.Build(
|
||||
_bumper,
|
||||
channel.BumperFont,
|
||||
template,
|
||||
variant,
|
||||
aligned,
|
||||
inputs
|
||||
new BumperSpecInputs(
|
||||
lines,
|
||||
audioPath,
|
||||
variant.Background == BumperBackground.Template ? null : posterPath,
|
||||
backgroundPath
|
||||
)
|
||||
);
|
||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||
}
|
||||
@@ -78,49 +87,100 @@ public sealed class RenderBumperPreviewCommandHandler(
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
|
||||
private async Task<string?> ResolveBackgroundPathAsync(
|
||||
BumperTemplate template,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
private static BumperContext BuildContext(Channel? channel, SampleShows samples)
|
||||
{
|
||||
if (template.BackgroundImageId is not { } imageId)
|
||||
return null;
|
||||
var offset = TimeSpan.FromMinutes(
|
||||
channel?.UtcOffsetMinutes ?? Channel.DefaultUtcOffsetMinutes
|
||||
);
|
||||
var moment = DateTimeOffset.UtcNow.ToOffset(offset);
|
||||
|
||||
var extension = await dbContext
|
||||
.Images.AsNoTracking()
|
||||
.Where(i => i.Id == imageId)
|
||||
.Select(i => i.FileExtension)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
|
||||
return new BumperContext(
|
||||
channel?.Name ?? "Канал",
|
||||
channel?.Number,
|
||||
moment,
|
||||
samples.NowTitle,
|
||||
samples.NextTitle,
|
||||
"с1э5",
|
||||
"с2э3",
|
||||
samples.NextYear,
|
||||
samples.NextGenre,
|
||||
TimeOnly.FromDateTime(moment.AddMinutes(30).DateTime),
|
||||
samples.SlotTitle
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
||||
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
||||
/// Пара шоу для образца. Берём те, что реально ходят в этом канале (через группы его слотов), —
|
||||
/// иначе предпросмотр показывает библиотеку, а не канал.
|
||||
/// </summary>
|
||||
private async Task<(string From, string To)> SampleNamesAsync(
|
||||
Channel channel,
|
||||
private async Task<SampleShows> SampleShowsAsync(
|
||||
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
|
||||
)
|
||||
var query = dbContext.Shows.AsNoTracking().AsQueryable();
|
||||
if (channel?.TemplateId is { } templateId)
|
||||
query =
|
||||
from show in query
|
||||
join item in dbContext.GroupItems.AsNoTracking() on show.Id equals item.ElementId
|
||||
join slot in dbContext.Slots.AsNoTracking() on item.GroupId equals slot.GroupId
|
||||
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
||||
where layer.TemplateId == templateId && item.ElementKind == GroupElementKind.Show
|
||||
select show;
|
||||
|
||||
var shows = await query
|
||||
.Select(s => new
|
||||
{
|
||||
s.Name,
|
||||
s.Year,
|
||||
s.PosterImageId,
|
||||
})
|
||||
.Distinct()
|
||||
.Take(2)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (
|
||||
names.ElementAtOrDefault(0) ?? "Первое шоу",
|
||||
names.ElementAtOrDefault(1) ?? "Второе шоу"
|
||||
var slotTitle = channel?.TemplateId is { } id
|
||||
? await (
|
||||
from slot in dbContext.Slots.AsNoTracking()
|
||||
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
||||
where layer.TemplateId == id
|
||||
select slot.Title
|
||||
).FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var next = shows.ElementAtOrDefault(1) ?? shows.ElementAtOrDefault(0);
|
||||
return new SampleShows(
|
||||
shows.ElementAtOrDefault(0)?.Name ?? "Первое шоу",
|
||||
next?.Name ?? "Второе шоу",
|
||||
next?.Year,
|
||||
null,
|
||||
next?.PosterImageId,
|
||||
slotTitle ?? "Вечернее кино"
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveImagePathAsync(
|
||||
Guid? imageId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (imageId is not { } id)
|
||||
return null;
|
||||
|
||||
var extension = await dbContext
|
||||
.Images.AsNoTracking()
|
||||
.Where(i => i.Id == id)
|
||||
.Select(i => i.FileExtension)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return extension is null ? null : imageStore.ResolvePath(id, extension);
|
||||
}
|
||||
|
||||
private sealed record SampleShows(
|
||||
string NowTitle,
|
||||
string NextTitle,
|
||||
int? NextYear,
|
||||
string? NextGenre,
|
||||
Guid? NextPosterId,
|
||||
string SlotTitle
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Отметить загруженный звук блока: расширение (с точкой) и длину в секундах (замер ffprobe).</summary>
|
||||
public sealed record SetBumperTemplateAudioCommand(
|
||||
Guid ChannelId,
|
||||
Guid TemplateId,
|
||||
string Extension,
|
||||
double DurationSeconds
|
||||
) : ICommand<Result>;
|
||||
+6
-9
@@ -1,5 +1,4 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
@@ -13,15 +12,13 @@ public sealed class SetBumperTemplateAudioCommandHandler(IAppDbContext dbContext
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
template.SetAudio(command.Extension, command.DurationSeconds);
|
||||
return Result.Success();
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Привязать фон-картинку блока по ссылке на изображение из реестра (галерея).</summary>
|
||||
public sealed record SetBumperTemplateBackgroundCommand(
|
||||
Guid ChannelId,
|
||||
Guid TemplateId,
|
||||
Guid ImageId
|
||||
) : ICommand<Result>;
|
||||
+10
-8
@@ -2,6 +2,7 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Images;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
@@ -13,15 +14,16 @@ public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbCo
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
if (!await dbContext.Images.AnyAsync(i => i.Id == command.ImageId, cancellationToken))
|
||||
return Result.Failure(ImageErrors.NotFound);
|
||||
|
||||
template.SetBackgroundImage(command.ImageId);
|
||||
return Result.Success();
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Обновить оформление блока заставки: имя и цвета (в нотации ffmpeg).</summary>
|
||||
public sealed record UpdateBumperTemplateCommand(
|
||||
Guid ChannelId,
|
||||
Guid TemplateId,
|
||||
string Name,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor
|
||||
) : ICommand<Result>;
|
||||
+8
-17
@@ -1,5 +1,4 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
@@ -13,23 +12,15 @@ public sealed class UpdateBumperTemplateCommandHandler(IAppDbContext dbContext)
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
|
||||
template.UpdateStyle(
|
||||
command.Name.Trim(),
|
||||
command.BackgroundColor,
|
||||
command.BackgroundColor2,
|
||||
command.AccentColor,
|
||||
command.TextColor
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
template.UpdateStyle(command.Style);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed partial class UpdateBumperTemplateCommandValidator
|
||||
: AbstractValidator<UpdateBumperTemplateCommand>
|
||||
{
|
||||
public UpdateBumperTemplateCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||
|
||||
// Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат
|
||||
// (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра.
|
||||
RuleFor(x => x.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
RuleFor(x => x.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
RuleFor(x => x.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
RuleFor(x => x.TextColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
}
|
||||
|
||||
private const string ColorMessage =
|
||||
"Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white).";
|
||||
|
||||
private static bool BeSafeColor(string? value) =>
|
||||
!string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value);
|
||||
|
||||
[GeneratedRegex(
|
||||
@"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$"
|
||||
)]
|
||||
private static partial Regex ColorRegex();
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Обновить подблок: имя, режим текста, текст, правило показа и вес.</summary>
|
||||
public sealed record UpdateBumperTextVariantCommand(
|
||||
Guid ChannelId,
|
||||
Guid TemplateId,
|
||||
Guid VariantId,
|
||||
string Name,
|
||||
BumperTextKind Kind,
|
||||
string NowLabel,
|
||||
string NextLabel,
|
||||
string Line1,
|
||||
string Line2,
|
||||
BumperTrigger Trigger,
|
||||
int Weight
|
||||
) : ICommand<Result>;
|
||||
-47
@@ -1,47 +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.Bumpers;
|
||||
|
||||
public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateBumperTextVariantCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateBumperTextVariantCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
|
||||
var variant = template.FindVariant(command.VariantId);
|
||||
if (variant is null)
|
||||
return Result.Failure(ChannelErrors.BumperTextVariantNotFound);
|
||||
|
||||
variant.Update(
|
||||
command.Name.Trim(),
|
||||
new BumperTextContent(
|
||||
command.Kind,
|
||||
command.NowLabel,
|
||||
command.NextLabel,
|
||||
command.Line1,
|
||||
command.Line2
|
||||
),
|
||||
command.Trigger,
|
||||
command.Weight
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class UpdateBumperTextVariantCommandValidator
|
||||
: AbstractValidator<UpdateBumperTextVariantCommand>
|
||||
{
|
||||
public UpdateBumperTextVariantCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.NowLabel).MaximumLength(64);
|
||||
RuleFor(x => x.NextLabel).MaximumLength(64);
|
||||
RuleFor(x => x.Line1).MaximumLength(120);
|
||||
RuleFor(x => x.Line2).MaximumLength(120);
|
||||
RuleFor(x => x.Weight).InclusiveBetween(0, 1000);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class UpdateBumperVariantCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateBumperVariantCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateBumperVariantCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
var variant = template.FindVariant(command.VariantId);
|
||||
if (variant is null)
|
||||
return Result.Failure(BumperErrors.VariantNotFound);
|
||||
|
||||
// Незнакомый плейсхолдер ловим здесь: в эфире он превратился бы в пустоту, и заметить это
|
||||
// было бы уже некому.
|
||||
var unknown = command
|
||||
.Input.Lines.SelectMany(l => BumperPlaceholders.UnknownTokens(l.Text))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (unknown.Count > 0)
|
||||
return Result.Failure(BumperErrors.UnknownPlaceholders(unknown));
|
||||
|
||||
variant.Update(
|
||||
command.Input.Name,
|
||||
command.Input.Trigger,
|
||||
command.Input.Background,
|
||||
command.Input.Weight
|
||||
);
|
||||
variant.SetLines(
|
||||
command.Input.Lines.Select(
|
||||
(line, index) => BumperLine.Create(index, line.Style, line.Color, line.Text)
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -4,39 +4,6 @@ namespace TeleWave.Application.Broadcast;
|
||||
|
||||
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
|
||||
|
||||
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
|
||||
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
|
||||
|
||||
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
|
||||
public sealed record BumperTextVariantDto(
|
||||
Guid Id,
|
||||
int Position,
|
||||
string Name,
|
||||
BumperTextKind Kind,
|
||||
string NowLabel,
|
||||
string NextLabel,
|
||||
string Line1,
|
||||
string Line2,
|
||||
BumperTrigger Trigger,
|
||||
int Weight
|
||||
);
|
||||
|
||||
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
||||
public sealed record BumperTemplateDto(
|
||||
Guid Id,
|
||||
int Position,
|
||||
bool IsDefault,
|
||||
string Name,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
Guid? BackgroundImageId,
|
||||
bool HasAudio,
|
||||
double? AudioDurationSeconds,
|
||||
IReadOnlyList<BumperTextVariantDto> Variants
|
||||
);
|
||||
|
||||
public sealed record ChannelDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
@@ -46,9 +13,6 @@ public sealed record ChannelDto(
|
||||
int UtcOffsetMinutes,
|
||||
TimeOnly DayStartTime,
|
||||
Guid? TemplateId,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsDto Bumper,
|
||||
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
||||
Guid? FillerAssetId,
|
||||
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
||||
ViewerSettingsDto Viewer
|
||||
|
||||
@@ -17,44 +17,10 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
||||
|
||||
var bumperTemplates = channel
|
||||
.BumperTemplates.OrderBy(t => t.Position)
|
||||
.Select(t => new BumperTemplateDto(
|
||||
t.Id,
|
||||
t.Position,
|
||||
t.IsDefault,
|
||||
t.Name,
|
||||
t.BackgroundColor,
|
||||
t.BackgroundColor2,
|
||||
t.AccentColor,
|
||||
t.TextColor,
|
||||
t.BackgroundImageId,
|
||||
t.AudioExtension is not null,
|
||||
t.AudioDurationSeconds,
|
||||
t.Variants.OrderBy(v => v.Position)
|
||||
.Select(v => new BumperTextVariantDto(
|
||||
v.Id,
|
||||
v.Position,
|
||||
v.Name,
|
||||
v.Kind,
|
||||
v.NowLabel,
|
||||
v.NextLabel,
|
||||
v.Line1,
|
||||
v.Line2,
|
||||
v.Trigger,
|
||||
v.Weight
|
||||
))
|
||||
.ToList()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return Result.Success(
|
||||
new ChannelDto(
|
||||
channel.Id,
|
||||
@@ -65,9 +31,6 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
channel.TemplateId,
|
||||
channel.BumpersEnabled,
|
||||
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
|
||||
bumperTemplates,
|
||||
channel.FillerAssetId,
|
||||
new ViewerSettingsDto(
|
||||
channel.LogoImageId,
|
||||
|
||||
+36
-45
@@ -1,5 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Library;
|
||||
@@ -43,22 +44,31 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
|
||||
// Подблоки заставок в окне — чтобы показать в расписании, какая именно заставка и с каким текстом.
|
||||
var variantIds = entries
|
||||
.Where(e =>
|
||||
e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper && e.BumperVariantId != null
|
||||
)
|
||||
.Select(e => e.BumperVariantId!.Value)
|
||||
// Заставки в окне: берём их из кэша по ассету — там лежит ровно тот текст, который играл,
|
||||
// с уже подставленными плейсхолдерами. Собирать его заново из подблока значило бы гадать.
|
||||
var bumperAssetIds = entries
|
||||
.Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper)
|
||||
.Select(e => e.MediaAssetId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var variants =
|
||||
variantIds.Count == 0
|
||||
var bumpers =
|
||||
bumperAssetIds.Count == 0
|
||||
? []
|
||||
: await dbContext
|
||||
.BumperTextVariants.AsNoTracking()
|
||||
.Where(v => variantIds.Contains(v.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
var variantsById = variants.ToDictionary(v => v.Id);
|
||||
: await (
|
||||
from cache in dbContext.BumperAssets.AsNoTracking()
|
||||
join variant in dbContext.BumperTextVariants.AsNoTracking()
|
||||
on cache.VariantId equals variant.Id
|
||||
where bumperAssetIds.Contains(cache.MediaAssetId)
|
||||
select new
|
||||
{
|
||||
cache.MediaAssetId,
|
||||
variant.Name,
|
||||
cache.RenderedLinesJson,
|
||||
}
|
||||
).ToListAsync(cancellationToken);
|
||||
var bumpersByAsset = bumpers
|
||||
.GroupBy(b => b.MediaAssetId)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
// Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки.
|
||||
var assetIds = entries
|
||||
@@ -72,21 +82,15 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
||||
.Select(a => new { a.Id, a.OriginalFileName })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||
|
||||
// «Из какого шоу» для заставки берём из ближайшей предыдущей программы в упорядоченном окне.
|
||||
Guid? prevProgramShowId = null;
|
||||
var dtos = new List<ScheduleEntryDto>(entries.Count);
|
||||
foreach (var e in entries)
|
||||
{
|
||||
string? bumperName = null;
|
||||
string? bumperText = null;
|
||||
if (
|
||||
e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper
|
||||
&& e.BumperVariantId is { } vid
|
||||
&& variantsById.TryGetValue(vid, out var variant)
|
||||
)
|
||||
if (bumpersByAsset.TryGetValue(e.MediaAssetId, out var bumper))
|
||||
{
|
||||
bumperName = variant.Name;
|
||||
bumperText = BumperText(variant, prevProgramShowId, e.ShowId, showNames);
|
||||
bumperName = bumper.Name;
|
||||
bumperText = BumperText(bumper.RenderedLinesJson);
|
||||
}
|
||||
|
||||
dtos.Add(
|
||||
@@ -106,34 +110,21 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
||||
bumperText
|
||||
)
|
||||
);
|
||||
|
||||
if (e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
|
||||
prevProgramShowId = e.ShowId;
|
||||
}
|
||||
|
||||
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Текст заставки для метки в расписании: для «Сейчас/Далее» — подписи + названия шоу (из→в),
|
||||
/// для свободного текста — заданные строки. Возвращает null, если показывать нечего.
|
||||
/// </summary>
|
||||
private static string? BumperText(
|
||||
Domain.Broadcast.BumperTextVariant variant,
|
||||
Guid? fromShowId,
|
||||
Guid? toShowId,
|
||||
IReadOnlyDictionary<Guid, string> showNames
|
||||
)
|
||||
/// <summary>Строки сыгравшей заставки одной меткой для расписания; null — показывать нечего.</summary>
|
||||
private static string? BumperText(string? renderedLinesJson)
|
||||
{
|
||||
if (variant.Kind == Domain.Broadcast.BumperTextKind.Free)
|
||||
{
|
||||
var parts = new[] { variant.Line1, variant.Line2 }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.ToArray();
|
||||
return parts.Length == 0 ? null : string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
string Name(Guid? id) => id is { } g ? showNames.GetValueOrDefault(g, "…") : "…";
|
||||
return $"{variant.NowLabel} {Name(fromShowId)} · {variant.NextLabel} {Name(toShowId)}";
|
||||
var text = string.Join(
|
||||
" · ",
|
||||
BumperRenderedText
|
||||
.FromJson(renderedLinesJson)
|
||||
.Select(l => l.Text)
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
);
|
||||
return text.Length == 0 ? null : text;
|
||||
}
|
||||
}
|
||||
|
||||
-7
@@ -1,6 +1,5 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
@@ -8,11 +7,5 @@ public sealed record UpdateChannelSettingsCommand(
|
||||
Guid ChannelId,
|
||||
string Name,
|
||||
bool IsEnabled,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsInput Bumper,
|
||||
Guid? FillerAssetId
|
||||
) : ICommand<Result>;
|
||||
|
||||
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
|
||||
/// показа сюда не входят — они задаются на элементе стыка.</summary>
|
||||
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
|
||||
|
||||
+1
-7
@@ -30,13 +30,7 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
||||
return Result.Failure(ChannelErrors.AssetNotFound);
|
||||
}
|
||||
|
||||
channel.UpdateSettings(
|
||||
command.Name,
|
||||
command.IsEnabled,
|
||||
command.BumpersEnabled,
|
||||
command.FillerAssetId
|
||||
);
|
||||
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
|
||||
channel.UpdateSettings(command.Name, command.IsEnabled, command.FillerAssetId);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ public interface IAppDbContext
|
||||
DbSet<JunctionElement> JunctionElements { get; }
|
||||
DbSet<Channel> Channels { get; }
|
||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||
DbSet<BumperTemplate> BumperTemplates { get; }
|
||||
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
||||
DbSet<BumperAsset> BumperAssets { get; }
|
||||
DbSet<AppSetting> AppSettings { get; }
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>Готовая строка заставки: роль, цвет из палитры блока и уже подставленный текст.</summary>
|
||||
public sealed record BumperRenderLine(BumperLineStyle Style, BumperLineColor Color, string Text);
|
||||
|
||||
/// <summary>
|
||||
/// Полная спецификация одной заставки для рендера: оформление канала + подписи + названия шоу.
|
||||
/// Полная спецификация одной заставки для рендера: оформление блока + готовые строки. Плейсхолдеры
|
||||
/// в <see cref="Lines"/> уже подставлены — рендер работает с текстом, а не с шаблоном.
|
||||
/// <see cref="DurationSeconds"/> уже выровнена на длину сегмента (готовит оркестратор), а
|
||||
/// <see cref="FontFile"/> — абсолютный путь к TTF внутри контейнера.
|
||||
/// </summary>
|
||||
@@ -14,18 +20,11 @@ public sealed record BumperRenderSpec(
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
string FontFile,
|
||||
string NowLabel,
|
||||
string NowTitle,
|
||||
string NextLabel,
|
||||
string NextTitle,
|
||||
IReadOnlyList<BumperRenderLine> Lines,
|
||||
string? BackgroundFile = null,
|
||||
string? MusicFile = null,
|
||||
/// <summary>Постер шоу как фон (используется, если нет загруженного фона канала; затемняется).</summary>
|
||||
string? PosterFile = null,
|
||||
/// <summary>Режим свободного текста: вместо «Сейчас/Далее» рисуются <see cref="FreeLine1"/>/<see cref="FreeLine2"/>.</summary>
|
||||
bool FreeText = false,
|
||||
string FreeLine1 = "",
|
||||
string FreeLine2 = ""
|
||||
/// <summary>Постер шоу как фон (используется, если подблок его запросил; затемняется).</summary>
|
||||
string? PosterFile = null
|
||||
);
|
||||
|
||||
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
||||
@@ -39,9 +38,9 @@ public sealed record BumperRenderResult(
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + текст
|
||||
/// «Сейчас/Далее» + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в
|
||||
/// assets/{assetId} — так же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
|
||||
/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + строки текста
|
||||
/// + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в assets/{assetId} — так
|
||||
/// же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
|
||||
/// </summary>
|
||||
public interface IBumperRenderer
|
||||
{
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Данные, которыми подставляются плейсхолдеры заставок одного прогона: названия шоу, годы, жанры,
|
||||
/// подписи серий, названия слотов.
|
||||
///
|
||||
/// Грузится только запрошенное: если ни в одной строке нет <c>{next.genre}</c>, жанры не читаются
|
||||
/// вовсе. Иначе каждая генерация тянула бы весь справочник ради текста, который никто не написал.
|
||||
/// </summary>
|
||||
internal sealed class BumperFacts
|
||||
{
|
||||
private readonly IReadOnlyList<PlannedItem> _items;
|
||||
private readonly TimeSpan _offset;
|
||||
private readonly Channel _channel;
|
||||
private readonly IReadOnlyDictionary<Guid, ShowFact> _shows;
|
||||
private readonly IReadOnlyDictionary<Guid, string> _slotTitles;
|
||||
|
||||
private BumperFacts(
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
Channel channel,
|
||||
IReadOnlyDictionary<Guid, ShowFact> shows,
|
||||
IReadOnlyDictionary<Guid, string> slotTitles
|
||||
)
|
||||
{
|
||||
_items = items;
|
||||
_channel = channel;
|
||||
_offset = TimeSpan.FromMinutes(channel.UtcOffsetMinutes);
|
||||
_shows = shows;
|
||||
_slotTitles = slotTitles;
|
||||
}
|
||||
|
||||
public static async Task<BumperFacts> LoadAsync(
|
||||
IAppDbContext dbContext,
|
||||
Channel channel,
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
IReadOnlySet<string> tokens,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = items
|
||||
.Where(i => i.Kind == PlannedItemKind.Bumper)
|
||||
.SelectMany(i => new[] { i.FromShowId, i.ToShowId })
|
||||
.Where(id => id is not null && id != Guid.Empty)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var shows =
|
||||
showIds.Count == 0
|
||||
? []
|
||||
: await LoadShowsAsync(dbContext, showIds, tokens, cancellationToken);
|
||||
|
||||
var slotTitles = tokens.Contains("slot")
|
||||
? await LoadSlotTitlesAsync(dbContext, items, cancellationToken)
|
||||
: new Dictionary<Guid, string>();
|
||||
|
||||
return new BumperFacts(items, channel, shows, slotTitles);
|
||||
}
|
||||
|
||||
/// <summary>Контекст одной заставки: соседи по ленте, время показа и данные канала.</summary>
|
||||
public BumperContext Context(PlannedItem item, int index)
|
||||
{
|
||||
var from = Show(item.FromShowId);
|
||||
var to = Show(item.ToShowId);
|
||||
var nextProgram = FindProgram(index, forward: true);
|
||||
var previousProgram = FindProgram(index, forward: false);
|
||||
|
||||
return new BumperContext(
|
||||
_channel.Name,
|
||||
_channel.Number,
|
||||
item.StartsAtUtc.ToOffset(_offset),
|
||||
from?.Name,
|
||||
to?.Name,
|
||||
EpisodeOf(from, previousProgram, item.FromShowId),
|
||||
EpisodeOf(to, nextProgram, item.ToShowId),
|
||||
to?.Year,
|
||||
to?.Genre,
|
||||
nextProgram is { } next
|
||||
? TimeOnly.FromDateTime(next.StartsAtUtc.ToOffset(_offset).DateTime)
|
||||
: null,
|
||||
item.SlotId is { } slotId && _slotTitles.TryGetValue(slotId, out var title)
|
||||
? title
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Подпись серии соседней программы — только если это та же самая программа.</summary>
|
||||
private static string? EpisodeOf(ShowFact? show, PlannedItem? neighbour, Guid? showId)
|
||||
{
|
||||
if (show is null || neighbour is null || neighbour.ShowId != showId)
|
||||
return null;
|
||||
return neighbour.UnitIndex is { } index && index >= 0 && index < show.Episodes.Count
|
||||
? show.Episodes[index]
|
||||
: null;
|
||||
}
|
||||
|
||||
private ShowFact? Show(Guid? showId) =>
|
||||
showId is { } id && _shows.TryGetValue(id, out var fact) ? fact : null;
|
||||
|
||||
/// <summary>Ближайшая программа по ленте в заданную сторону — стык может быть длиннее одной врезки.</summary>
|
||||
private PlannedItem? FindProgram(int index, bool forward)
|
||||
{
|
||||
var step = forward ? 1 : -1;
|
||||
for (var i = index + step; i >= 0 && i < _items.Count; i += step)
|
||||
if (_items[i].Kind == PlannedItemKind.Program)
|
||||
return _items[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<Guid, ShowFact>> LoadShowsAsync(
|
||||
IAppDbContext dbContext,
|
||||
IReadOnlyList<Guid> showIds,
|
||||
IReadOnlySet<string> tokens,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Year,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var genres = tokens.Contains("next.genre")
|
||||
? await (
|
||||
from link in dbContext.ShowGenres.AsNoTracking()
|
||||
join genre in dbContext.Genres.AsNoTracking() on link.GenreId equals genre.Id
|
||||
where showIds.Contains(link.ShowId) && link.IsPrimary
|
||||
select new { link.ShowId, genre.Name }
|
||||
).ToDictionaryAsync(g => g.ShowId, g => g.Name, cancellationToken)
|
||||
: [];
|
||||
|
||||
var episodes =
|
||||
tokens.Contains("next.episode") || tokens.Contains("now.episode")
|
||||
? await LoadEpisodesAsync(dbContext, showIds, cancellationToken)
|
||||
: [];
|
||||
|
||||
return shows.ToDictionary(
|
||||
s => s.Id,
|
||||
s => new ShowFact(
|
||||
s.Name,
|
||||
s.Year,
|
||||
genres.GetValueOrDefault(s.Id),
|
||||
episodes.GetValueOrDefault(s.Id) ?? []
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подписи серий в том же порядке, в каком их разворачивает планировщик: только серии с готовым
|
||||
/// ассетом, по позиции. Иначе номер в заставке разошёлся бы с тем, что реально играет.
|
||||
/// </summary>
|
||||
private static async Task<Dictionary<Guid, List<string?>>> LoadEpisodesAsync(
|
||||
IAppDbContext dbContext,
|
||||
IReadOnlyList<Guid> showIds,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var rows = await (
|
||||
from show in dbContext.Shows.AsNoTracking()
|
||||
from episode in show.Episodes
|
||||
join asset in dbContext.MediaAssets.AsNoTracking()
|
||||
on episode.MediaAssetId equals asset.Id
|
||||
where
|
||||
showIds.Contains(show.Id)
|
||||
&& asset.Status == MediaAssetStatus.Ready
|
||||
&& asset.Duration != null
|
||||
orderby episode.Position
|
||||
select new
|
||||
{
|
||||
show.Id,
|
||||
episode.Season,
|
||||
episode.Episode,
|
||||
episode.Title,
|
||||
}
|
||||
).ToListAsync(cancellationToken);
|
||||
|
||||
return rows.GroupBy(r => r.Id)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g =>
|
||||
g.Select(r => BumperPlaceholders.Episode(r.Season, r.Episode) ?? r.Title)
|
||||
.ToList<string?>()
|
||||
);
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<Guid, string>> LoadSlotTitlesAsync(
|
||||
IAppDbContext dbContext,
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var slotIds = items
|
||||
.Select(i => i.SlotId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return await dbContext
|
||||
.Slots.AsNoTracking()
|
||||
.Where(s => slotIds.Contains(s.Id))
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record ShowFact(
|
||||
string Name,
|
||||
int? Year,
|
||||
string? Genre,
|
||||
IReadOnlyList<string?> Episodes
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
@@ -10,21 +11,14 @@ using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning;
|
||||
|
||||
/// <summary>Ключ отрендеренной заставки: блок, подблок и пара шоу, между которыми она стоит.</summary>
|
||||
public readonly record struct BumperKey(
|
||||
Guid TemplateId,
|
||||
Guid VariantId,
|
||||
Guid FromShowId,
|
||||
Guid ToShowId
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены
|
||||
/// намеренно: ассет зависит от пары соседей, а пара известна только после того, как слоты наполнены.
|
||||
/// намеренно: текст заставки зависит от пары соседей и времени показа, а они известны только после
|
||||
/// того, как слоты наполнены.
|
||||
///
|
||||
/// Готовый ассет переиспользуется по сигнатуре (пара названий + версия блока), недостающий
|
||||
/// регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру. Запись при
|
||||
/// этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
|
||||
/// Готовый ассет переиспользуется по сигнатуре содержимого (оформление блока + подставленный текст),
|
||||
/// недостающий регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру.
|
||||
/// Запись при этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
|
||||
/// </summary>
|
||||
public sealed class BumperResolver(
|
||||
IAppDbContext dbContext,
|
||||
@@ -32,120 +26,175 @@ public sealed class BumperResolver(
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
public async Task<IReadOnlyDictionary<BumperKey, Guid>> ResolveAsync(
|
||||
/// <summary>Ассеты заставок по индексу записи в ленте: одна и та же пара шоу может дать разный текст.</summary>
|
||||
public async Task<IReadOnlyDictionary<int, Guid>> ResolveAsync(
|
||||
Channel channel,
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var reserved = items.Where(i => i.Kind == PlannedItemKind.Bumper).ToList();
|
||||
var reserved = items
|
||||
.Select((item, index) => (item, index))
|
||||
.Where(pair => pair.item.Kind == PlannedItemKind.Bumper)
|
||||
.ToList();
|
||||
if (reserved.Count == 0)
|
||||
return new Dictionary<BumperKey, Guid>();
|
||||
return new Dictionary<int, Guid>();
|
||||
|
||||
var showNames = await LoadShowNamesAsync(reserved, cancellationToken);
|
||||
var result = new Dictionary<BumperKey, Guid>();
|
||||
var templates = await LoadTemplatesAsync(reserved, cancellationToken);
|
||||
if (templates.Count == 0)
|
||||
return new Dictionary<int, Guid>();
|
||||
|
||||
// Кэш существующих заставок канала: одна пара шоу встречается в горизонте многократно.
|
||||
var existing = await dbContext
|
||||
.BumperAssets.Where(b => b.ChannelId == channel.Id)
|
||||
.ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
|
||||
var tokens = BumperPlaceholders.TokensIn(
|
||||
templates
|
||||
.Values.SelectMany(t => t.Variants)
|
||||
.SelectMany(v => v.Lines)
|
||||
.Select(l => l.Text)
|
||||
);
|
||||
var facts = await BumperFacts.LoadAsync(
|
||||
dbContext,
|
||||
channel,
|
||||
items,
|
||||
tokens,
|
||||
cancellationToken
|
||||
);
|
||||
var requests = new List<(int Index, BumperRequest Request)>();
|
||||
|
||||
foreach (var item in reserved)
|
||||
foreach (var (item, index) in reserved)
|
||||
{
|
||||
if (
|
||||
item.BumperTemplateId is not { } templateId
|
||||
|| channel.FindBumperTemplate(templateId) is not { } template
|
||||
|| !templates.TryGetValue(templateId, out var template)
|
||||
)
|
||||
continue;
|
||||
|
||||
var fromShowId = item.FromShowId ?? Guid.Empty;
|
||||
var toShowId = item.ToShowId ?? Guid.Empty;
|
||||
var variant = PickVariant(template, fromShowId != toShowId, channel, random);
|
||||
var variant = PickVariant(template, item.BumperVariantId, fromShowId != toShowId);
|
||||
if (variant is null)
|
||||
continue;
|
||||
|
||||
var key = new BumperKey(templateId, variant.Id, fromShowId, toShowId);
|
||||
if (result.ContainsKey(key))
|
||||
continue;
|
||||
var context = facts.Context(item, index);
|
||||
var lines = variant
|
||||
.Lines.OrderBy(l => l.Position)
|
||||
.Select(l => new BumperRenderLine(
|
||||
l.Style,
|
||||
l.Color,
|
||||
BumperPlaceholders.Resolve(l.Text, context)
|
||||
))
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l.Text))
|
||||
.ToList();
|
||||
|
||||
var fromName = showNames.GetValueOrDefault(fromShowId, "—");
|
||||
var toName = showNames.GetValueOrDefault(toShowId, "—");
|
||||
var signature = Signature(template, variant.Id, fromName, toName);
|
||||
|
||||
if (existing.TryGetValue(signature, out var assetId))
|
||||
var posterShowId = variant.Background switch
|
||||
{
|
||||
result[key] = assetId;
|
||||
BumperBackground.NextPoster when toShowId != Guid.Empty => toShowId,
|
||||
BumperBackground.NowPoster when fromShowId != Guid.Empty => fromShowId,
|
||||
_ => (Guid?)null,
|
||||
};
|
||||
|
||||
var linesJson = BumperRenderedText.ToJson(lines);
|
||||
requests.Add(
|
||||
(
|
||||
index,
|
||||
new BumperRequest(
|
||||
template,
|
||||
variant.Id,
|
||||
linesJson,
|
||||
posterShowId,
|
||||
Signature(template, variant.Id, linesJson, posterShowId)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return await MaterializeAsync(requests, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Заводит недостающие ассеты и раздаёт готовые по записям ленты.</summary>
|
||||
private async Task<IReadOnlyDictionary<int, Guid>> MaterializeAsync(
|
||||
IReadOnlyList<(int Index, BumperRequest Request)> requests,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var signatures = requests.Select(r => r.Request.Signature).Distinct().ToList();
|
||||
var existing = await dbContext
|
||||
.BumperAssets.Where(b => signatures.Contains(b.Signature))
|
||||
.ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
|
||||
|
||||
var result = new Dictionary<int, Guid>();
|
||||
foreach (var (index, request) in requests)
|
||||
{
|
||||
if (existing.TryGetValue(request.Signature, out var assetId))
|
||||
{
|
||||
result[index] = assetId;
|
||||
continue;
|
||||
}
|
||||
|
||||
var asset = MediaAsset.RegisterGenerated($"{template.Name}: {fromName} → {toName}");
|
||||
var asset = MediaAsset.RegisterGenerated(request.Caption);
|
||||
dbContext.MediaAssets.Add(asset);
|
||||
dbContext.BumperAssets.Add(
|
||||
BumperAsset.Create(
|
||||
channel.Id,
|
||||
templateId,
|
||||
variant.Id,
|
||||
fromShowId,
|
||||
toShowId,
|
||||
signature,
|
||||
request.Template.Id,
|
||||
request.VariantId,
|
||||
request.Signature,
|
||||
request.Lines,
|
||||
request.PosterShowId,
|
||||
asset.Id
|
||||
)
|
||||
);
|
||||
|
||||
existing[signature] = asset.Id;
|
||||
result[key] = asset.Id;
|
||||
existing[request.Signature] = asset.Id;
|
||||
result[index] = asset.Id;
|
||||
renderQueue.Enqueue(asset.Id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подблок, подходящий под контекст перехода: на смене шоу и между сериями одного играют разные
|
||||
/// тексты. Стратегия выбора — общая настройка канала.
|
||||
/// </summary>
|
||||
private static BumperTextVariant? PickVariant(
|
||||
BumperTemplate template,
|
||||
bool isShowChange,
|
||||
Channel channel,
|
||||
IRandomSource random
|
||||
private async Task<Dictionary<Guid, BumperTemplate>> LoadTemplatesAsync(
|
||||
IReadOnlyList<(PlannedItem Item, int Index)> reserved,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var eligible = template
|
||||
.Variants.Where(v =>
|
||||
v.Trigger switch
|
||||
{
|
||||
BumperTrigger.OnShowChange => isShowChange,
|
||||
BumperTrigger.BetweenEpisodes => !isShowChange,
|
||||
_ => true,
|
||||
}
|
||||
)
|
||||
.OrderBy(v => v.Position)
|
||||
var ids = reserved
|
||||
.Select(r => r.Item.BumperTemplateId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return await dbContext
|
||||
.BumperTemplates.AsNoTracking()
|
||||
.Include(t => t.Variants)
|
||||
.Where(t => ids.Contains(t.Id))
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подблок: либо жёстко заданный врезкой, либо подходящий под контекст перехода — на смене шоу
|
||||
/// и между сериями одного играют разные тексты. Среди подходящих выбор по весам.
|
||||
/// </summary>
|
||||
private BumperTextVariant? PickVariant(
|
||||
BumperTemplate template,
|
||||
Guid? fixedVariantId,
|
||||
bool isShowChange
|
||||
)
|
||||
{
|
||||
if (fixedVariantId is { } id)
|
||||
return template.FindVariant(id);
|
||||
|
||||
var eligible = template
|
||||
.Variants.Where(v => v.Matches(isShowChange))
|
||||
.OrderBy(v => v.Position)
|
||||
.ToList();
|
||||
if (eligible.Count == 0)
|
||||
return null;
|
||||
|
||||
return channel.BumperSelection switch
|
||||
{
|
||||
BumperSelection.AlwaysFirst => eligible[0],
|
||||
BumperSelection.Random => eligible[random.Next(eligible.Count)],
|
||||
BumperSelection.WeightedRandom => WeightedPick(eligible, random),
|
||||
_ => eligible[random.Next(eligible.Count)],
|
||||
};
|
||||
}
|
||||
|
||||
private static BumperTextVariant WeightedPick(
|
||||
IReadOnlyList<BumperTextVariant> eligible,
|
||||
IRandomSource random
|
||||
)
|
||||
{
|
||||
var total = eligible.Sum(v => (long)Math.Max(0, v.Weight));
|
||||
var total = eligible.Sum(v => Math.Max(0, v.Weight));
|
||||
if (total <= 0)
|
||||
return eligible[random.Next(eligible.Count)];
|
||||
|
||||
var roll = random.Next((int)Math.Min(total, int.MaxValue));
|
||||
long accumulated = 0;
|
||||
var roll = random.Next(total);
|
||||
var accumulated = 0;
|
||||
foreach (var variant in eligible)
|
||||
{
|
||||
accumulated += Math.Max(0, variant.Weight);
|
||||
@@ -157,41 +206,44 @@ public sealed class BumperResolver(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сигнатура включает версию блока: замена звука или фона обязана пересобрать заставки, иначе
|
||||
/// в эфире осталась бы старая картинка с новым оформлением рядом.
|
||||
/// Сигнатура — хэш содержимого: оформление блока с его ревизией плюс подставленный текст. Канала
|
||||
/// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а
|
||||
/// <c>{channel}</c> в тексте разводит их сам собой.
|
||||
/// </summary>
|
||||
private static string Signature(
|
||||
BumperTemplate template,
|
||||
Guid variantId,
|
||||
string fromName,
|
||||
string toName
|
||||
string linesJson,
|
||||
Guid? posterShowId
|
||||
)
|
||||
{
|
||||
var raw = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{template.Id}|{variantId}|{template.Revision}|{fromName}|{toName}"
|
||||
$"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{linesJson}"
|
||||
);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32];
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
||||
IReadOnlyList<PlannedItem> reserved,
|
||||
CancellationToken cancellationToken
|
||||
/// <summary>Что нужно отрендерить для одной записи ленты.</summary>
|
||||
private sealed record BumperRequest(
|
||||
BumperTemplate Template,
|
||||
Guid VariantId,
|
||||
string Lines,
|
||||
Guid? PosterShowId,
|
||||
string Signature
|
||||
)
|
||||
{
|
||||
var showIds = reserved
|
||||
.SelectMany(i => new[] { i.FromShowId, i.ToShowId })
|
||||
.Where(id => id is not null && id != Guid.Empty)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (showIds.Count == 0)
|
||||
return [];
|
||||
|
||||
return await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
/// <summary>Имя ассета для админки: блок и первые строки заставки.</summary>
|
||||
public string Caption
|
||||
{
|
||||
get
|
||||
{
|
||||
var text = string.Join(
|
||||
" / ",
|
||||
BumperRenderedText.FromJson(Lines).Select(l => l.Text).Take(2)
|
||||
);
|
||||
return text.Length == 0 ? Template.Name : $"{Template.Name}: {text}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,14 +57,10 @@ public sealed class GridScheduleGenerator(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
// Вложенные коллекции тянем отдельными запросами: иначе колонки родителя (у шаблона —
|
||||
// jsonb с правилами, у слоя — jsonb применимости) приезжают по копии на каждую строку
|
||||
// листа. Тик планировщика повторяет эти два запроса на каждый канал.
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
||||
c => c.Id == channelId,
|
||||
cancellationToken
|
||||
);
|
||||
if (channel is null || !channel.IsEnabled || channel.TemplateId is null)
|
||||
return new GenerationReport(0, [], ChannelSkipped: true);
|
||||
|
||||
@@ -129,12 +125,13 @@ public sealed class GridScheduleGenerator(
|
||||
);
|
||||
|
||||
var added = 0;
|
||||
foreach (var item in result.Items)
|
||||
for (var index = 0; index < result.Items.Count; index++)
|
||||
{
|
||||
var item = result.Items[index];
|
||||
var assetId = item.MediaAssetId;
|
||||
if (
|
||||
item.Kind == PlannedItemKind.Bumper
|
||||
&& !TryResolveBumper(item, bumperAssets, out assetId)
|
||||
&& !bumperAssets.TryGetValue(index, out assetId)
|
||||
)
|
||||
continue; // Без ассета запись стала бы дырой в ленте.
|
||||
|
||||
@@ -184,9 +181,6 @@ public sealed class GridScheduleGenerator(
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||
if (channel is null || channel.TemplateId is null)
|
||||
return null;
|
||||
@@ -280,13 +274,17 @@ public sealed class GridScheduleGenerator(
|
||||
);
|
||||
|
||||
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
|
||||
// что и группы контента.
|
||||
// что и группы контента. Стыки общие, поэтому фильтра по каналу нет.
|
||||
var junctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Include(j => j.Elements)
|
||||
.Where(j => j.ChannelId == channel.Id)
|
||||
.ToDictionaryAsync(j => j.Id, cancellationToken);
|
||||
|
||||
// Блоки заставок нужны только длительностью — текст подставит резолвер после сборки ленты.
|
||||
var bumperTemplates = await dbContext
|
||||
.BumperTemplates.AsNoTracking()
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
|
||||
var groupIds = scheduled
|
||||
.Select(s => s.Slot.GroupId)
|
||||
.Where(id => id is not null)
|
||||
@@ -361,12 +359,19 @@ public sealed class GridScheduleGenerator(
|
||||
elements,
|
||||
cursor,
|
||||
repeatUnits,
|
||||
BuildJunction(slot.JunctionBetweenId, junctions, elementsByGroup, channel),
|
||||
BuildJunction(
|
||||
slot.JunctionBetweenId,
|
||||
junctions,
|
||||
elementsByGroup,
|
||||
bumperTemplates,
|
||||
slot.Daypart
|
||||
),
|
||||
BuildJunction(
|
||||
slot.JunctionAfterId ?? template.DefaultJunctionId,
|
||||
junctions,
|
||||
elementsByGroup,
|
||||
channel
|
||||
bumperTemplates,
|
||||
slot.Daypart
|
||||
),
|
||||
rules?.AudienceAt(
|
||||
TimeOnly.FromDateTime(
|
||||
@@ -388,44 +393,18 @@ public sealed class GridScheduleGenerator(
|
||||
horizonEnd,
|
||||
slots,
|
||||
fallback,
|
||||
_segmentSeconds
|
||||
_segmentSeconds,
|
||||
channel.UtcOffsetMinutes
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Ассет заставки по зарезервированной записи; false — подобрать не удалось.</summary>
|
||||
private static bool TryResolveBumper(
|
||||
PlannedItem item,
|
||||
IReadOnlyDictionary<BumperKey, Guid> bumperAssets,
|
||||
out Guid assetId
|
||||
)
|
||||
{
|
||||
assetId = Guid.Empty;
|
||||
if (item.BumperTemplateId is not { } templateId)
|
||||
return false;
|
||||
|
||||
// Подблок выбирает резолвер, поэтому ищем по блоку и паре шоу.
|
||||
foreach (var pair in bumperAssets)
|
||||
{
|
||||
if (
|
||||
pair.Key.TemplateId == templateId
|
||||
&& pair.Key.FromShowId == (item.FromShowId ?? Guid.Empty)
|
||||
&& pair.Key.ToShowId == (item.ToShowId ?? Guid.Empty)
|
||||
)
|
||||
{
|
||||
assetId = pair.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Разворачивает шаблон стыка для планировщика, включая резерв под заставки.</summary>
|
||||
private PlanningJunction? BuildJunction(
|
||||
Guid? junctionId,
|
||||
IReadOnlyDictionary<Guid, JunctionTemplate> junctions,
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
|
||||
Channel channel
|
||||
IReadOnlyDictionary<Guid, BumperTemplate> bumperTemplates,
|
||||
Daypart daypart
|
||||
)
|
||||
{
|
||||
if (junctionId is not { } id || !junctions.TryGetValue(id, out var template))
|
||||
@@ -437,57 +416,83 @@ public sealed class GridScheduleGenerator(
|
||||
var conditions =
|
||||
JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions();
|
||||
|
||||
if (element.Kind == JunctionElementKind.Bumper)
|
||||
{
|
||||
// Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
|
||||
// резервирует именно её, ассет подставит резолвер после сборки ленты.
|
||||
if (
|
||||
element.BumperTemplateId is not { } bumperTemplateId
|
||||
|| channel.FindBumperTemplate(bumperTemplateId) is not { } bumperTemplate
|
||||
)
|
||||
continue;
|
||||
|
||||
var seconds = BumperDuration.Aligned(
|
||||
BumperDuration.TemplateSeconds(bumperTemplate),
|
||||
_segmentSeconds
|
||||
);
|
||||
|
||||
elements.Add(
|
||||
new PlanningJunctionElement(
|
||||
element.Kind,
|
||||
[],
|
||||
element.AmountMode,
|
||||
element.AmountValue,
|
||||
element.IsRequired,
|
||||
conditions.OnlyOnElementChange,
|
||||
conditions.MinMinutesBetween,
|
||||
bumperTemplateId,
|
||||
TimeSpan.FromSeconds(seconds)
|
||||
)
|
||||
);
|
||||
// Дейпарт — свойство слота, а не момента: отсекаем здесь, чтобы домен не знал про сетку.
|
||||
if (!conditions.AllowsDaypart(daypart))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
element.GroupId is not { } groupId
|
||||
|| !elementsByGroup.TryGetValue(groupId, out var groupElements)
|
||||
)
|
||||
var units = ResolveUnits(element, elementsByGroup, bumperTemplates, out var bumper);
|
||||
if (units is null)
|
||||
continue;
|
||||
|
||||
elements.Add(
|
||||
new PlanningJunctionElement(
|
||||
element.Id,
|
||||
element.Kind,
|
||||
groupElements.SelectMany(e => e.Units).ToList(),
|
||||
units,
|
||||
element.AmountMode,
|
||||
element.AmountValue,
|
||||
element.IsRequired,
|
||||
conditions.OnlyOnElementChange,
|
||||
conditions.MinMinutesBetween
|
||||
conditions.MinMinutesBetween,
|
||||
conditions.Chance,
|
||||
conditions.TimeWindow is { } window
|
||||
? new PlanningTimeWindow(window.From, window.To)
|
||||
: null,
|
||||
element.ChoiceKey,
|
||||
element.ChoiceWeight,
|
||||
element.BumperTemplateId,
|
||||
element.BumperVariantId,
|
||||
bumper
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return elements.Count == 0 ? null : new PlanningJunction(id, elements);
|
||||
return elements.Count == 0
|
||||
? null
|
||||
: new PlanningJunction(
|
||||
id,
|
||||
elements,
|
||||
template.MaxTotalSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : null
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Что играет во врезке: единицы группы либо резерв под заставку. null — врезка настроена
|
||||
/// не до конца (нет группы или блока), и в эфир ей идти нечем.
|
||||
/// </summary>
|
||||
private IReadOnlyList<PlanningUnit>? ResolveUnits(
|
||||
JunctionElement element,
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
|
||||
IReadOnlyDictionary<Guid, BumperTemplate> bumperTemplates,
|
||||
out TimeSpan bumperDuration
|
||||
)
|
||||
{
|
||||
bumperDuration = TimeSpan.Zero;
|
||||
|
||||
if (element.Kind == JunctionElementKind.Bumper)
|
||||
{
|
||||
// Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
|
||||
// резервирует именно её, ассет подставит резолвер после сборки ленты.
|
||||
if (
|
||||
element.BumperTemplateId is not { } templateId
|
||||
|| !bumperTemplates.TryGetValue(templateId, out var bumperTemplate)
|
||||
)
|
||||
return null;
|
||||
|
||||
bumperDuration = TimeSpan.FromSeconds(
|
||||
BumperDuration.Aligned(
|
||||
BumperDuration.TemplateSeconds(bumperTemplate),
|
||||
_segmentSeconds
|
||||
)
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return
|
||||
element.GroupId is { } groupId
|
||||
&& elementsByGroup.TryGetValue(groupId, out var groupElements)
|
||||
? groupElements.SelectMany(e => e.Units).ToList()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+5
-12
@@ -5,22 +5,15 @@ using TeleWave.Application.Common.Models;
|
||||
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||
|
||||
/// <summary>
|
||||
/// Копирует сетку канала на другой канал: слои, слоты, стыки и правила. Группы не копируются —
|
||||
/// они общие для всех каналов. Прежний шаблон канала-приёмника заменяется целиком.
|
||||
/// Копирует сетку канала на другой канал: слои, слоты и правила. Группы, стыки и заставки
|
||||
/// не копируются — они общие для всех каналов, копия ссылается на те же. Прежний шаблон
|
||||
/// канала-приёмника заменяется целиком.
|
||||
/// </summary>
|
||||
public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId)
|
||||
: ICommand<Result<CopyTemplateResultDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// Что скопировалось. <paramref name="DroppedBumperRefs"/> — врезки-заставки, для которых на канале
|
||||
/// -приёмнике не нашлось блока с таким же именем: ссылка снята, врезку надо донастроить руками.
|
||||
/// </summary>
|
||||
public sealed record CopyTemplateResultDto(
|
||||
int Layers,
|
||||
int Slots,
|
||||
int Junctions,
|
||||
int DroppedBumperRefs
|
||||
);
|
||||
/// <summary>Что скопировалось.</summary>
|
||||
public sealed record CopyTemplateResultDto(int Layers, int Slots);
|
||||
|
||||
public sealed class CopyTemplateCommandValidator : AbstractValidator<CopyTemplateCommand>
|
||||
{
|
||||
|
||||
+13
-126
@@ -15,9 +15,10 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var target = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
|
||||
var target = await dbContext.Channels.FirstOrDefaultAsync(
|
||||
c => c.Id == command.TargetChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
if (target is null)
|
||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
|
||||
|
||||
@@ -30,138 +31,31 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||
if (source is null)
|
||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var sourceJunctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Include(j => j.Elements)
|
||||
.Where(j => j.ChannelId == command.SourceChannelId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
|
||||
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
|
||||
var bumperByName = target
|
||||
.BumperTemplates.GroupBy(t => t.Name)
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
var sourceBumperNames = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Where(c => c.Id == command.SourceChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.Select(t => new { t.Id, t.Name })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||
|
||||
var (junctionMap, droppedBumperRefs) = CopyJunctions(
|
||||
sourceJunctions,
|
||||
target.Id,
|
||||
sourceBumperNames,
|
||||
bumperByName
|
||||
);
|
||||
|
||||
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
|
||||
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
|
||||
var existing = await dbContext
|
||||
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.ScheduleTemplates.RemoveRange(existing);
|
||||
await dbContext
|
||||
.JunctionTemplates.Where(j =>
|
||||
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
|
||||
)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
|
||||
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
|
||||
copyTemplate.SetRules(source.RulesJson);
|
||||
if (
|
||||
source.DefaultJunctionId is { } defaultJunction
|
||||
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
|
||||
)
|
||||
copyTemplate.SetDefaultJunction(mappedDefault);
|
||||
// Стыки и заставки общие для всех каналов — копия ссылается на те же, без перевешивания.
|
||||
copyTemplate.SetDefaultJunction(source.DefaultJunctionId);
|
||||
|
||||
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
|
||||
var (layers, slots) = CopyGrid(source, copyTemplate);
|
||||
|
||||
dbContext.ScheduleTemplates.Add(copyTemplate);
|
||||
target.SetTemplate(copyTemplate.Id);
|
||||
|
||||
return Result.Success(
|
||||
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
|
||||
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
|
||||
/// </summary>
|
||||
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
|
||||
IReadOnlyList<JunctionTemplate> sourceJunctions,
|
||||
Guid targetChannelId,
|
||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
||||
IReadOnlyDictionary<string, Guid> targetBumperByName
|
||||
)
|
||||
{
|
||||
var map = new Dictionary<Guid, Guid>();
|
||||
var dropped = 0;
|
||||
|
||||
foreach (var junction in sourceJunctions)
|
||||
{
|
||||
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
|
||||
map[junction.Id] = copy.Id;
|
||||
|
||||
foreach (var element in junction.Elements.OrderBy(e => e.Position))
|
||||
{
|
||||
var bumperTemplateId = MapBumper(
|
||||
element,
|
||||
sourceBumperNames,
|
||||
targetBumperByName,
|
||||
ref dropped
|
||||
);
|
||||
copy.AddElement(element.Kind)
|
||||
.Update(
|
||||
element.Kind,
|
||||
element.GroupId,
|
||||
bumperTemplateId,
|
||||
element.AmountMode,
|
||||
element.AmountValue,
|
||||
element.IsRequired,
|
||||
element.ConditionsJson
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.JunctionTemplates.Add(copy);
|
||||
}
|
||||
|
||||
return (map, dropped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
|
||||
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
|
||||
/// и это попадает в отчёт.
|
||||
/// </summary>
|
||||
private static Guid? MapBumper(
|
||||
JunctionElement element,
|
||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
||||
IReadOnlyDictionary<string, Guid> targetBumperByName,
|
||||
ref int dropped
|
||||
)
|
||||
{
|
||||
if (element.Kind != JunctionElementKind.Bumper)
|
||||
return null;
|
||||
|
||||
if (
|
||||
element.BumperTemplateId is { } sourceId
|
||||
&& sourceBumperNames.TryGetValue(sourceId, out var name)
|
||||
&& targetBumperByName.TryGetValue(name, out var mapped)
|
||||
)
|
||||
return mapped;
|
||||
|
||||
dropped++;
|
||||
return null;
|
||||
return Result.Success(new CopyTemplateResultDto(layers, slots));
|
||||
}
|
||||
|
||||
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
|
||||
private static (int Layers, int Slots) CopyGrid(
|
||||
ScheduleTemplate source,
|
||||
ScheduleTemplate copyTemplate,
|
||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
||||
ScheduleTemplate copyTemplate
|
||||
)
|
||||
{
|
||||
var layers = 0;
|
||||
@@ -179,7 +73,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||
|
||||
foreach (var slot in layer.Slots)
|
||||
{
|
||||
CopySlot(slot, copyLayer, junctionMap);
|
||||
CopySlot(slot, copyLayer);
|
||||
slots++;
|
||||
}
|
||||
}
|
||||
@@ -187,11 +81,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||
return (layers, slots);
|
||||
}
|
||||
|
||||
private static void CopySlot(
|
||||
Slot slot,
|
||||
GridLayer copyLayer,
|
||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
||||
)
|
||||
private static void CopySlot(Slot slot, GridLayer copyLayer)
|
||||
{
|
||||
var copySlot = copyLayer.AddSlot(
|
||||
slot.Title,
|
||||
@@ -220,12 +110,9 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy,
|
||||
Map(slot.JunctionBetweenId, junctionMap),
|
||||
Map(slot.JunctionAfterId, junctionMap)
|
||||
slot.JunctionBetweenId,
|
||||
slot.JunctionAfterId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
|
||||
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
@@ -11,7 +12,13 @@ public sealed record JunctionConditions(
|
||||
/// <summary>Ставить только при смене шоу, а не между сериями одного.</summary>
|
||||
bool OnlyOnElementChange = false,
|
||||
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
||||
int MinMinutesBetween = 0
|
||||
int MinMinutesBetween = 0,
|
||||
/// <summary>Только в эти дейпарты (пусто — в любые).</summary>
|
||||
IReadOnlyList<Daypart>? Dayparts = null,
|
||||
/// <summary>Только в это окно суток канала (null — в любое время).</summary>
|
||||
JunctionTimeWindow? TimeWindow = null,
|
||||
/// <summary>Вероятность показа в процентах; 100 — всегда.</summary>
|
||||
int Chance = 100
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
@@ -21,6 +28,10 @@ public sealed record JunctionConditions(
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Действует ли врезка в этом дейпарте.</summary>
|
||||
public bool AllowsDaypart(Daypart daypart) =>
|
||||
Dayparts is not { Count: > 0 } || Dayparts.Contains(daypart);
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public static JunctionConditions? FromJson(string? json)
|
||||
@@ -38,3 +49,6 @@ public sealed record JunctionConditions(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Окно суток канала; допускает переход через полночь («с 23:00 до 06:00»).</summary>
|
||||
public sealed record JunctionTimeWindow(TimeOnly From, TimeOnly To);
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
|
||||
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
|
||||
|
||||
var element = junction.AddElement(command.Kind);
|
||||
await JunctionLoader.MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
|
||||
await JunctionLoader.MarkTemplatesChangedAsync(dbContext, junction.Id, cancellationToken);
|
||||
return Result.Success(element.Id);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-8
@@ -1,6 +1,4 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
@@ -10,16 +8,13 @@ namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
public Task<Result<Guid>> Handle(
|
||||
CreateJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
|
||||
var junction = JunctionTemplate.Create(command.Name);
|
||||
dbContext.JunctionTemplates.Add(junction);
|
||||
return Result.Success(junction.Id);
|
||||
return Task.FromResult(Result.Success(junction.Id));
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -21,19 +21,19 @@ public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
|
||||
if (junction is null)
|
||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||
|
||||
// Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
|
||||
var used = await dbContext.Slots.AnyAsync(
|
||||
// Стык общий: слот чужого канала, ссылающийся на удалённый стык, молча остался бы без врезок.
|
||||
var usedBySlot = await dbContext.Slots.AnyAsync(
|
||||
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
|
||||
cancellationToken
|
||||
);
|
||||
if (used)
|
||||
var usedByDefault = await dbContext.ScheduleTemplates.AnyAsync(
|
||||
t => t.DefaultJunctionId == junction.Id,
|
||||
cancellationToken
|
||||
);
|
||||
if (usedBySlot || usedByDefault)
|
||||
return Result.Failure(TemplateErrors.JunctionInUse);
|
||||
|
||||
dbContext.JunctionTemplates.Remove(junction);
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
+32
-8
@@ -5,12 +5,12 @@ using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed record ListJunctionsQuery(Guid ChannelId)
|
||||
: IQuery<IReadOnlyList<JunctionTemplateDto>>;
|
||||
public sealed record ListJunctionsQuery : IQuery<IReadOnlyList<JunctionTemplateDto>>;
|
||||
|
||||
public sealed record CreateJunctionCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
|
||||
public sealed record CreateJunctionCommand(string Name) : ICommand<Result<Guid>>;
|
||||
|
||||
public sealed record RenameJunctionCommand(Guid JunctionId, string Name) : ICommand<Result>;
|
||||
public sealed record UpdateJunctionCommand(Guid JunctionId, string Name, int? MaxTotalSeconds)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed record DeleteJunctionCommand(Guid JunctionId) : ICommand<Result>;
|
||||
|
||||
@@ -22,9 +22,13 @@ public sealed record JunctionElementInput(
|
||||
JunctionElementKind Kind,
|
||||
Guid? GroupId,
|
||||
Guid? BumperTemplateId,
|
||||
Guid? BumperVariantId,
|
||||
JunctionAmountMode AmountMode,
|
||||
int AmountValue,
|
||||
bool IsRequired,
|
||||
/// <summary>Метка развилки: из врезок с одной меткой играет одна, выбранная по весам.</summary>
|
||||
string? ChoiceKey,
|
||||
int ChoiceWeight,
|
||||
JunctionConditions? Conditions
|
||||
);
|
||||
|
||||
@@ -37,17 +41,32 @@ public sealed record UpdateJunctionElementCommand(
|
||||
public sealed record RemoveJunctionElementCommand(Guid JunctionId, Guid ElementId)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed record ReorderJunctionCommand(Guid JunctionId, IReadOnlyList<Guid> ElementIdsInOrder)
|
||||
: ICommand<Result>;
|
||||
/// <summary>
|
||||
/// Позиция врезки вместе с её развилкой: перетаскивание в цепочке одновременно меняет и порядок,
|
||||
/// и принадлежность к развилке, поэтому отдельной команды «сгруппировать» нет.
|
||||
/// </summary>
|
||||
public sealed record JunctionElementOrder(Guid ElementId, string? ChoiceKey);
|
||||
|
||||
public sealed record ReorderJunctionCommand(
|
||||
Guid JunctionId,
|
||||
IReadOnlyList<JunctionElementOrder> Order
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed class CreateJunctionCommandValidator : AbstractValidator<CreateJunctionCommand>
|
||||
{
|
||||
public CreateJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
|
||||
public sealed class RenameJunctionCommandValidator : AbstractValidator<RenameJunctionCommand>
|
||||
public sealed class UpdateJunctionCommandValidator : AbstractValidator<UpdateJunctionCommand>
|
||||
{
|
||||
public RenameJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
public UpdateJunctionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
// Потолок стыка в сутки — верхняя граница здравого смысла, а не техническое ограничение.
|
||||
RuleFor(x => x.MaxTotalSeconds)
|
||||
.InclusiveBetween(1, 24 * 60 * 60)
|
||||
.When(x => x.MaxTotalSeconds is not null);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateJunctionElementCommandValidator
|
||||
@@ -57,8 +76,13 @@ public sealed class UpdateJunctionElementCommandValidator
|
||||
{
|
||||
// Верхняя граница — сутки: врезка длиннее вещательного дня бессмысленна.
|
||||
RuleFor(x => x.Input.AmountValue).InclusiveBetween(1, 24 * 60);
|
||||
RuleFor(x => x.Input.ChoiceKey).MaximumLength(64);
|
||||
RuleFor(x => x.Input.ChoiceWeight).InclusiveBetween(0, 1000);
|
||||
RuleFor(x => x.Input.Conditions!.MinMinutesBetween)
|
||||
.InclusiveBetween(0, 24 * 60)
|
||||
.When(x => x.Input.Conditions is not null);
|
||||
RuleFor(x => x.Input.Conditions!.Chance)
|
||||
.InclusiveBetween(0, 100)
|
||||
.When(x => x.Input.Conditions is not null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,21 @@ public sealed record JunctionElementDto(
|
||||
string? GroupName,
|
||||
Guid? BumperTemplateId,
|
||||
string? BumperTemplateName,
|
||||
Guid? BumperVariantId,
|
||||
string? BumperVariantName,
|
||||
JunctionAmountMode AmountMode,
|
||||
int AmountValue,
|
||||
bool IsRequired,
|
||||
string? ChoiceKey,
|
||||
int ChoiceWeight,
|
||||
JunctionConditions? Conditions
|
||||
);
|
||||
|
||||
public sealed record JunctionTemplateDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
int? MaxTotalSeconds,
|
||||
/// <summary>Сколько каналов ссылается на стык — он общий, и это надо видеть до правки.</summary>
|
||||
int ChannelUsageCount,
|
||||
IReadOnlyList<JunctionElementDto> Elements
|
||||
);
|
||||
|
||||
@@ -17,18 +17,37 @@ internal static class JunctionLoader
|
||||
.JunctionTemplates.Include(j => j.Elements)
|
||||
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
|
||||
|
||||
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
|
||||
public static async Task<Result> MarkTemplateChangedAsync(
|
||||
/// <summary>
|
||||
/// Правка стыка — правка правил эфира. Стык общий, поэтому изменёнными помечаются все шаблоны,
|
||||
/// которые на него ссылаются: иначе чужой канал молча поехал бы по новым врезкам без применения.
|
||||
/// </summary>
|
||||
public static async Task<Result> MarkTemplatesChangedAsync(
|
||||
IAppDbContext dbContext,
|
||||
JunctionTemplate junction,
|
||||
Guid junctionId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
|
||||
t => t.ChannelId == junction.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
template?.MarkChanged();
|
||||
var viaSlots = await dbContext
|
||||
.Slots.AsNoTracking()
|
||||
.Where(s => s.JunctionBetweenId == junctionId || s.JunctionAfterId == junctionId)
|
||||
.Join(
|
||||
dbContext.GridLayers.AsNoTracking(),
|
||||
slot => slot.LayerId,
|
||||
layer => layer.Id,
|
||||
(_, layer) => layer.TemplateId
|
||||
)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var templates = await dbContext
|
||||
.ScheduleTemplates.Where(t =>
|
||||
viaSlots.Contains(t.Id) || t.DefaultJunctionId == junctionId
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var template in templates)
|
||||
template.MarkChanged();
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
+69
-20
@@ -15,53 +15,102 @@ public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
|
||||
var junctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Include(j => j.Elements)
|
||||
.Where(j => j.ChannelId == query.ChannelId)
|
||||
.OrderBy(j => j.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
|
||||
var groupIds = junctions
|
||||
.SelectMany(j => j.Elements)
|
||||
.Select(e => e.GroupId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
// Имена групп и заставок резолвим одним проходом — редактор показывает их сразу.
|
||||
var groupIds = Ids(junctions, e => e.GroupId);
|
||||
var groupNames = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||
|
||||
var bumperNames = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Where(c => c.Id == query.ChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||
var templateIds = Ids(junctions, e => e.BumperTemplateId);
|
||||
var bumpers = await dbContext
|
||||
.BumperTemplates.AsNoTracking()
|
||||
.Where(t => templateIds.Contains(t.Id))
|
||||
.Include(t => t.Variants)
|
||||
.ToListAsync(cancellationToken);
|
||||
var bumperNames = bumpers.ToDictionary(t => t.Id, t => t.Name);
|
||||
var variantNames = bumpers.SelectMany(t => t.Variants).ToDictionary(v => v.Id, v => v.Name);
|
||||
|
||||
var usage = await ChannelUsageAsync(cancellationToken);
|
||||
|
||||
return junctions
|
||||
.Select(j => new JunctionTemplateDto(
|
||||
j.Id,
|
||||
j.Name,
|
||||
j.MaxTotalSeconds,
|
||||
usage.GetValueOrDefault(j.Id),
|
||||
j.Elements.OrderBy(e => e.Position)
|
||||
.Select(e => new JunctionElementDto(
|
||||
e.Id,
|
||||
e.Position,
|
||||
e.Kind,
|
||||
e.GroupId,
|
||||
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
|
||||
? gname
|
||||
: null,
|
||||
Lookup(groupNames, e.GroupId),
|
||||
e.BumperTemplateId,
|
||||
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
|
||||
? bname
|
||||
: null,
|
||||
Lookup(bumperNames, e.BumperTemplateId),
|
||||
e.BumperVariantId,
|
||||
Lookup(variantNames, e.BumperVariantId),
|
||||
e.AmountMode,
|
||||
e.AmountValue,
|
||||
e.IsRequired,
|
||||
e.ChoiceKey,
|
||||
e.ChoiceWeight,
|
||||
JunctionConditions.FromJson(e.ConditionsJson)
|
||||
))
|
||||
.ToList()
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Сколько каналов ссылается на каждый стык — слотами сетки либо стыком по умолчанию.</summary>
|
||||
private async Task<Dictionary<Guid, int>> ChannelUsageAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var viaSlots = await (
|
||||
from slot in dbContext.Slots.AsNoTracking()
|
||||
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
||||
join template in dbContext.ScheduleTemplates.AsNoTracking()
|
||||
on layer.TemplateId equals template.Id
|
||||
where slot.JunctionBetweenId != null || slot.JunctionAfterId != null
|
||||
select new
|
||||
{
|
||||
template.ChannelId,
|
||||
slot.JunctionBetweenId,
|
||||
slot.JunctionAfterId,
|
||||
}
|
||||
).ToListAsync(cancellationToken);
|
||||
|
||||
var viaDefault = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Where(t => t.DefaultJunctionId != null)
|
||||
.Select(t => new { t.ChannelId, t.DefaultJunctionId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var pairs = viaSlots
|
||||
.SelectMany(s =>
|
||||
new[] { s.JunctionBetweenId, s.JunctionAfterId }
|
||||
.Where(id => id is not null)
|
||||
.Select(id => (JunctionId: id!.Value, s.ChannelId))
|
||||
)
|
||||
.Concat(viaDefault.Select(d => (JunctionId: d.DefaultJunctionId!.Value, d.ChannelId)));
|
||||
|
||||
return pairs.Distinct().GroupBy(p => p.JunctionId).ToDictionary(g => g.Key, g => g.Count());
|
||||
}
|
||||
|
||||
private static List<Guid> Ids(
|
||||
IEnumerable<Domain.Programming.JunctionTemplate> junctions,
|
||||
Func<Domain.Programming.JunctionElement, Guid?> selector
|
||||
) =>
|
||||
junctions
|
||||
.SelectMany(j => j.Elements)
|
||||
.Select(selector)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
private static string? Lookup(IReadOnlyDictionary<Guid, string> names, Guid? id) =>
|
||||
id is { } value && names.TryGetValue(value, out var name) ? name : null;
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,9 +20,9 @@ public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
|
||||
if (junction is null || !junction.RemoveElement(command.ElementId))
|
||||
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
||||
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
return await JunctionLoader.MarkTemplatesChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
junction.Id,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
+8
-3
@@ -20,10 +20,15 @@ public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
|
||||
if (junction is null)
|
||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||
|
||||
junction.Reorder(command.ElementIdsInOrder);
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
// Порядок и принадлежность к развилке приезжают вместе: в цепочке это один жест мышью.
|
||||
foreach (var position in command.Order)
|
||||
if (junction.FindElement(position.ElementId) is { } element)
|
||||
element.SetChoice(position.ChoiceKey, element.ChoiceWeight);
|
||||
|
||||
junction.Reorder(command.Order.Select(o => o.ElementId));
|
||||
return await JunctionLoader.MarkTemplatesChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
junction.Id,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
+6
-6
@@ -4,11 +4,11 @@ using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||
|
||||
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RenameJunctionCommand, Result>
|
||||
public sealed class UpdateJunctionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateJunctionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RenameJunctionCommand command,
|
||||
UpdateJunctionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
@@ -20,10 +20,10 @@ public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
|
||||
if (junction is null)
|
||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||
|
||||
junction.Rename(command.Name);
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
junction.Update(command.Name, command.MaxTotalSeconds);
|
||||
return await JunctionLoader.MarkTemplatesChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
junction.Id,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
+49
-27
@@ -1,6 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
@@ -25,38 +25,60 @@ public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
|
||||
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
||||
|
||||
var input = command.Input;
|
||||
|
||||
if (input.Kind == JunctionElementKind.Bumper)
|
||||
{
|
||||
var known = await dbContext
|
||||
.Channels.Where(c => c.Id == junction.ChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
|
||||
if (!known)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (input.GroupId is not { } groupId)
|
||||
return Result.Failure(TemplateErrors.JunctionGroupRequired);
|
||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
}
|
||||
var check = await ValidateSourceAsync(input, cancellationToken);
|
||||
if (!check.IsSuccess)
|
||||
return check;
|
||||
|
||||
element.Update(
|
||||
input.Kind,
|
||||
input.GroupId,
|
||||
input.BumperTemplateId,
|
||||
input.AmountMode,
|
||||
input.AmountValue,
|
||||
input.IsRequired,
|
||||
input.Conditions?.ToJson()
|
||||
new JunctionElementSettings(
|
||||
input.Kind,
|
||||
input.GroupId,
|
||||
input.BumperTemplateId,
|
||||
input.BumperVariantId,
|
||||
input.AmountMode,
|
||||
input.AmountValue,
|
||||
input.IsRequired,
|
||||
input.ChoiceKey,
|
||||
input.ChoiceWeight,
|
||||
input.Conditions?.ToJson()
|
||||
)
|
||||
);
|
||||
|
||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
||||
return await JunctionLoader.MarkTemplatesChangedAsync(
|
||||
dbContext,
|
||||
junction,
|
||||
junction.Id,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Источник врезки должен существовать: молча пустая врезка выглядит как «стык не работает».</summary>
|
||||
private async Task<Result> ValidateSourceAsync(
|
||||
JunctionElementInput input,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (input.Kind != JunctionElementKind.Bumper)
|
||||
{
|
||||
if (input.GroupId is not { } groupId)
|
||||
return Result.Failure(TemplateErrors.JunctionGroupRequired);
|
||||
return await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
|
||||
? Result.Success()
|
||||
: Result.Failure(TemplateErrors.GroupNotFound);
|
||||
}
|
||||
|
||||
if (input.BumperTemplateId is not { } templateId)
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
if (!await dbContext.BumperTemplates.AnyAsync(t => t.Id == templateId, cancellationToken))
|
||||
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||
|
||||
if (input.BumperVariantId is not { } variantId)
|
||||
return Result.Success();
|
||||
|
||||
return await dbContext.BumperTextVariants.AnyAsync(
|
||||
v => v.Id == variantId && v.BumperTemplateId == templateId,
|
||||
cancellationToken
|
||||
)
|
||||
? Result.Success()
|
||||
: Result.Failure(BumperErrors.VariantNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user