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:
+112
-110
@@ -1,35 +1,88 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Broadcast;
|
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
|
/// <summary>
|
||||||
public static partial class ChannelEndpoints
|
/// Блоки ТВ-заставок: оформление, звук, подблоки с текстом и рендер превью. Блоки общие для всех
|
||||||
|
/// каналов, поэтому и раздел свой, не канальный — канал только ссылается на них врезками стыков.
|
||||||
|
/// </summary>
|
||||||
|
public static class BumperEndpoints
|
||||||
{
|
{
|
||||||
private static async Task<IResult> AddBumperTemplate(
|
public static IEndpointRouteBuilder MapBumperEndpoints(this IEndpointRouteBuilder app)
|
||||||
Guid id,
|
{
|
||||||
AddBumperTemplateBody body,
|
var admin = app.MapGroup("/api/admin/bumpers")
|
||||||
|
.WithTags("Admin.Bumpers")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("", List).Produces<IReadOnlyList<BumperTemplateDto>>();
|
||||||
|
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
|
admin.MapPut("/{templateId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapDelete("/{templateId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
admin
|
||||||
|
.MapPut("/{templateId:guid}/audio", UploadAudio)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapDelete("/{templateId:guid}/audio", ClearAudio)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPut("/{templateId:guid}/background", SetBackground)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapDelete("/{templateId:guid}/background", ClearBackground)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
admin
|
||||||
|
.MapPost("/{templateId:guid}/variants", AddVariant)
|
||||||
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
|
admin
|
||||||
|
.MapPut("/{templateId:guid}/variants/{variantId:guid}", UpdateVariant)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapDelete("/{templateId:guid}/variants/{variantId:guid}", RemoveVariant)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
admin
|
||||||
|
.MapPost("/{templateId:guid}/preview", RenderPreview)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/index.m3u8", PreviewPlaylist);
|
||||||
|
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/{file}", PreviewSegment);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListBumperTemplatesQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Create(
|
||||||
|
BumperNameBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new AddBumperTemplateCommand(id, body.Name),
|
new CreateBumperTemplateCommand(body.Name),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
? Results.Created(
|
||||||
|
$"/api/admin/bumpers/{result.Value}",
|
||||||
|
new CreatedIdResponse(result.Value)
|
||||||
|
)
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateBumperTemplate(
|
private static async Task<IResult> Update(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
UpdateBumperTemplateBody body,
|
UpdateBumperTemplateBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
@@ -38,35 +91,37 @@ public static partial class ChannelEndpoints
|
|||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateBumperTemplateCommand(
|
new UpdateBumperTemplateCommand(
|
||||||
id,
|
|
||||||
templateId,
|
templateId,
|
||||||
body.Name,
|
new BumperStyle(
|
||||||
body.BackgroundColor,
|
body.Name,
|
||||||
body.BackgroundColor2,
|
body.Font,
|
||||||
body.AccentColor,
|
body.BackgroundColor,
|
||||||
body.TextColor
|
body.BackgroundColor2,
|
||||||
|
body.AccentColor,
|
||||||
|
body.TextColor
|
||||||
|
)
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RemoveBumperTemplate(
|
private static async Task<IResult> Delete(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new RemoveBumperTemplateCommand(id, templateId),
|
new DeleteBumperTemplateCommand(templateId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UploadTemplateAudio(
|
private static async Task<IResult> UploadAudio(
|
||||||
[AsParameters] BumperAudioUpload upload,
|
Guid templateId,
|
||||||
|
string fileName,
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
IBumperTemplateStorage storage,
|
IBumperTemplateStorage storage,
|
||||||
IAudioProbe probe,
|
IAudioProbe probe,
|
||||||
@@ -74,124 +129,101 @@ public static partial class ChannelEndpoints
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (
|
if (ResolveExtension(fileName, request) is not { } ext)
|
||||||
ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions)
|
return BumperErrors.InvalidFile.ToProblem();
|
||||||
is not { } ext
|
|
||||||
)
|
|
||||||
return ChannelErrors.InvalidBumperFile.ToProblem();
|
|
||||||
|
|
||||||
await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken);
|
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
|
||||||
|
|
||||||
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
||||||
var path = storage.AudioPath(upload.TemplateId, ext);
|
var path = storage.AudioPath(templateId, ext);
|
||||||
var duration = path is null
|
var duration = path is null
|
||||||
? null
|
? null
|
||||||
: await probe.TryGetDurationAsync(path, cancellationToken);
|
: await probe.TryGetDurationAsync(path, cancellationToken);
|
||||||
|
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new SetBumperTemplateAudioCommand(
|
new SetBumperTemplateAudioCommand(templateId, ext, duration?.TotalSeconds ?? 0),
|
||||||
upload.Id,
|
|
||||||
upload.TemplateId,
|
|
||||||
ext,
|
|
||||||
duration?.TotalSeconds ?? 0
|
|
||||||
),
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken);
|
await storage.DeleteAudioAsync(templateId, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ClearTemplateAudio(
|
private static async Task<IResult> ClearAudio(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ClearBumperTemplateAudioCommand(id, templateId),
|
new ClearBumperTemplateAudioCommand(templateId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> SetTemplateBackground(
|
private static async Task<IResult> SetBackground(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
SetBumperTemplateBackgroundBody body,
|
SetBumperBackgroundBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
|
new SetBumperTemplateBackgroundCommand(templateId, body.ImageId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ClearTemplateBackground(
|
private static async Task<IResult> ClearBackground(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ClearBumperTemplateBackgroundCommand(id, templateId),
|
new ClearBumperTemplateBackgroundCommand(templateId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> AddBumperVariant(
|
private static async Task<IResult> AddVariant(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
AddBumperVariantBody body,
|
BumperNameBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new AddBumperTextVariantCommand(id, templateId, body.Name),
|
new AddBumperVariantCommand(templateId, body.Name),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
? Results.Created(
|
||||||
|
$"/api/admin/bumpers/{templateId}",
|
||||||
|
new CreatedIdResponse(result.Value)
|
||||||
|
)
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateBumperVariant(
|
private static async Task<IResult> UpdateVariant(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
Guid variantId,
|
Guid variantId,
|
||||||
UpdateBumperVariantBody body,
|
BumperVariantInput input,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateBumperTextVariantCommand(
|
new UpdateBumperVariantCommand(templateId, variantId, input),
|
||||||
id,
|
|
||||||
templateId,
|
|
||||||
variantId,
|
|
||||||
body.Name,
|
|
||||||
body.Kind,
|
|
||||||
body.NowLabel,
|
|
||||||
body.NextLabel,
|
|
||||||
body.Line1,
|
|
||||||
body.Line2,
|
|
||||||
body.Trigger,
|
|
||||||
body.Weight
|
|
||||||
),
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RemoveBumperVariant(
|
private static async Task<IResult> RemoveVariant(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
Guid variantId,
|
Guid variantId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
@@ -199,7 +231,7 @@ public static partial class ChannelEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new RemoveBumperTextVariantCommand(id, templateId, variantId),
|
new RemoveBumperVariantCommand(templateId, variantId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -207,33 +239,27 @@ public static partial class ChannelEndpoints
|
|||||||
|
|
||||||
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
||||||
private static async Task<IResult> RenderPreview(
|
private static async Task<IResult> RenderPreview(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
|
Guid? channelId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new RenderBumperPreviewCommand(id, templateId),
|
new RenderBumperPreviewCommand(templateId, channelId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||||
private static IResult PreviewPlaylist(
|
private static IResult PreviewPlaylist(Guid templateId, Guid variantId, MediaPathResolver paths)
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
MediaPathResolver paths
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var previewId = BumperPreview.AssetId(variantId);
|
var previewId = BumperPreview.AssetId(variantId);
|
||||||
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
|
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
var baseUrl =
|
var baseUrl = $"/api/admin/bumpers/{templateId}/preview/{variantId}/";
|
||||||
$"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/";
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
foreach (var line in File.ReadLines(indexPath))
|
foreach (var line in File.ReadLines(indexPath))
|
||||||
{
|
{
|
||||||
@@ -249,9 +275,8 @@ public static partial class ChannelEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью
|
/// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется
|
||||||
/// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные
|
/// подблоком (см. <see cref="BumperPreview.AssetId"/>) — незаявленные параметры не связываются.
|
||||||
/// параметры маршрута просто не связываются.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
|
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
|
||||||
{
|
{
|
||||||
@@ -267,50 +292,27 @@ public static partial class ChannelEndpoints
|
|||||||
|
|
||||||
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
|
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
|
||||||
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
|
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
|
||||||
private static string? ResolveBumperExtension(
|
private static string? ResolveExtension(string fileName, HttpRequest request)
|
||||||
string fileName,
|
|
||||||
HttpRequest request,
|
|
||||||
IReadOnlySet<string> allowedExtensions
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
|
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
|
||||||
return null;
|
return null;
|
||||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
return allowedExtensions.Contains(ext) ? ext : null;
|
return BumperFiles.AudioExtensions.Contains(ext) ? ext : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AddBumperTemplateBody(string Name);
|
public sealed record BumperNameBody(string Name);
|
||||||
|
|
||||||
public sealed record UpdateBumperTemplateBody(
|
public sealed record UpdateBumperTemplateBody(
|
||||||
string Name,
|
string Name,
|
||||||
|
BumperFont Font,
|
||||||
string BackgroundColor,
|
string BackgroundColor,
|
||||||
string BackgroundColor2,
|
string BackgroundColor2,
|
||||||
string AccentColor,
|
string AccentColor,
|
||||||
string TextColor
|
string TextColor
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record SetBumperTemplateBackgroundBody(Guid ImageId);
|
public sealed record SetBumperBackgroundBody(Guid ImageId);
|
||||||
|
|
||||||
public sealed record AddBumperVariantBody(string Name);
|
|
||||||
|
|
||||||
public sealed record UpdateBumperVariantBody(
|
|
||||||
string Name,
|
|
||||||
BumperTextKind Kind,
|
|
||||||
string NowLabel,
|
|
||||||
string NextLabel,
|
|
||||||
string Line1,
|
|
||||||
string Line2,
|
|
||||||
BumperTrigger Trigger,
|
|
||||||
int Weight
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему
|
|
||||||
/// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два
|
|
||||||
/// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName);
|
|
||||||
|
|
||||||
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
|
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
|
||||||
internal static class BumperFiles
|
internal static class BumperFiles
|
||||||
@@ -15,11 +15,11 @@ using TeleWave.Infrastructure.Identity;
|
|||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
|
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
|
||||||
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
|
/// и стыки общие для всех каналов и живут своими разделами (<c>BumperEndpoints</c>,
|
||||||
/// (<c>TemplateEndpoints</c>).
|
/// <c>JunctionEndpoints</c>); что и когда идёт в эфире, задаёт шаблон сетки (<c>TemplateEndpoints</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static partial class ChannelEndpoints
|
public static class ChannelEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
@@ -35,62 +35,6 @@ public static partial class ChannelEndpoints
|
|||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin
|
|
||||||
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPut(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
|
||||||
SetTemplateBackground
|
|
||||||
)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
|
||||||
ClearTemplateBackground
|
|
||||||
)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin.MapGet(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
|
|
||||||
PreviewPlaylist
|
|
||||||
);
|
|
||||||
admin.MapGet(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
|
|
||||||
PreviewSegment
|
|
||||||
);
|
|
||||||
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin
|
|
||||||
.MapPut(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
|
||||||
UpdateBumperVariant
|
|
||||||
)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
|
||||||
RemoveBumperVariant
|
|
||||||
)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapGet("/{id:guid}/schedule", GetSchedule)
|
.MapGet("/{id:guid}/schedule", GetSchedule)
|
||||||
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
||||||
@@ -191,14 +135,7 @@ public static partial class ChannelEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateChannelSettingsCommand(
|
new UpdateChannelSettingsCommand(id, body.Name, body.IsEnabled, body.FillerAssetId),
|
||||||
id,
|
|
||||||
body.Name,
|
|
||||||
body.IsEnabled,
|
|
||||||
body.BumpersEnabled,
|
|
||||||
body.Bumper,
|
|
||||||
body.FillerAssetId
|
|
||||||
),
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -229,13 +166,7 @@ public sealed record UpdateChannelTimeBody(
|
|||||||
TimeOnly DayStartTime
|
TimeOnly DayStartTime
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record UpdateChannelSettingsBody(
|
public sealed record UpdateChannelSettingsBody(string Name, bool IsEnabled, Guid? FillerAssetId);
|
||||||
string Name,
|
|
||||||
bool IsEnabled,
|
|
||||||
bool BumpersEnabled,
|
|
||||||
BumperSettingsInput Bumper,
|
|
||||||
Guid? FillerAssetId
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
|
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
|
||||||
public sealed record UpdateViewerSettingsBody(
|
public sealed record UpdateViewerSettingsBody(
|
||||||
|
|||||||
@@ -7,67 +7,50 @@ using TeleWave.Infrastructure.Identity;
|
|||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Шаблоны стыков канала: что играет между программами. Как и правка сетки, эфира не двигают —
|
/// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только
|
||||||
/// помечают шаблон канала изменённым, а хвост пересобирается применением.
|
/// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов,
|
||||||
|
/// которые их используют, изменёнными, а хвост пересобирается применением.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class JunctionEndpoints
|
public static class JunctionEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin")
|
var admin = app.MapGroup("/api/admin/junctions")
|
||||||
.WithTags("Admin.Junctions")
|
.WithTags("Admin.Junctions")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin
|
admin.MapGet("", List).Produces<IReadOnlyList<JunctionTemplateDto>>();
|
||||||
.MapGet("/channels/{channelId:guid}/junctions", List)
|
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
.Produces<IReadOnlyList<JunctionTemplateDto>>();
|
admin.MapPut("/{junctionId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin.MapDelete("/{junctionId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
.MapPost("/channels/{channelId:guid}/junctions", Create)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin
|
|
||||||
.MapPut("/junctions/{junctionId:guid}", Rename)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete("/junctions/{junctionId:guid}", Delete)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapPost("/junctions/{junctionId:guid}/elements", AddElement)
|
.MapPost("/{junctionId:guid}/elements", AddElement)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin
|
admin
|
||||||
.MapPut("/junctions/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
|
.MapPut("/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapDelete("/junctions/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
|
.MapDelete("/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPut("/junctions/{junctionId:guid}/order", Reorder)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapPut("/{junctionId:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> List(
|
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
|
||||||
Guid channelId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListJunctionsQuery(channelId), cancellationToken);
|
var result = await sender.Send(new ListJunctionsQuery(), cancellationToken);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Create(
|
private static async Task<IResult> Create(
|
||||||
Guid channelId,
|
|
||||||
JunctionNameBody body,
|
JunctionNameBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(new CreateJunctionCommand(body.Name), cancellationToken);
|
||||||
new CreateJunctionCommand(channelId, body.Name),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created(
|
? Results.Created(
|
||||||
$"/api/admin/junctions/{result.Value}",
|
$"/api/admin/junctions/{result.Value}",
|
||||||
@@ -76,15 +59,15 @@ public static class JunctionEndpoints
|
|||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Rename(
|
private static async Task<IResult> Update(
|
||||||
Guid junctionId,
|
Guid junctionId,
|
||||||
JunctionNameBody body,
|
UpdateJunctionBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new RenameJunctionCommand(junctionId, body.Name),
|
new UpdateJunctionCommand(junctionId, body.Name, body.MaxTotalSeconds),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -156,7 +139,7 @@ public static class JunctionEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ReorderJunctionCommand(junctionId, body.ElementIdsInOrder),
|
new ReorderJunctionCommand(junctionId, body.Order),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -165,6 +148,9 @@ public static class JunctionEndpoints
|
|||||||
|
|
||||||
public sealed record JunctionNameBody(string Name);
|
public sealed record JunctionNameBody(string Name);
|
||||||
|
|
||||||
|
public sealed record UpdateJunctionBody(string Name, int? MaxTotalSeconds);
|
||||||
|
|
||||||
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
|
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
|
||||||
|
|
||||||
public sealed record ReorderJunctionBody(IReadOnlyList<Guid> ElementIdsInOrder);
|
/// <summary>Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом.</summary>
|
||||||
|
public sealed record ReorderJunctionBody(IReadOnlyList<JunctionElementOrder> Order);
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ app.MapInterstitialEndpoints();
|
|||||||
app.MapCollectionEndpoints();
|
app.MapCollectionEndpoints();
|
||||||
app.MapGroupEndpoints();
|
app.MapGroupEndpoints();
|
||||||
app.MapTemplateEndpoints();
|
app.MapTemplateEndpoints();
|
||||||
|
app.MapBumperEndpoints();
|
||||||
app.MapJunctionEndpoints();
|
app.MapJunctionEndpoints();
|
||||||
app.MapChannelEndpoints();
|
app.MapChannelEndpoints();
|
||||||
app.MapStreamingEndpoints();
|
app.MapStreamingEndpoints();
|
||||||
|
|||||||
@@ -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;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (пути к постеру/фону/звуку,
|
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (готовые строки, пути к
|
||||||
/// названия шоу). Общая точка для фонового рендерера заставок расписания и превью в админке.
|
/// постеру/фону/звуку). Общая точка для фонового рендерера заставок расписания и превью в админке.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BumperSpecFactory
|
public static class BumperSpecFactory
|
||||||
{
|
{
|
||||||
public static BumperRenderSpec Build(
|
public static BumperRenderSpec Build(
|
||||||
BumperOptions bumper,
|
BumperOptions bumper,
|
||||||
BumperFont font,
|
|
||||||
BumperTemplate template,
|
BumperTemplate template,
|
||||||
BumperTextVariant variant,
|
|
||||||
int alignedDurationSeconds,
|
int alignedDurationSeconds,
|
||||||
BumperSpecInputs inputs
|
BumperSpecInputs inputs
|
||||||
)
|
) =>
|
||||||
{
|
new(
|
||||||
var free = variant.Kind == BumperTextKind.Free;
|
|
||||||
return new BumperRenderSpec(
|
|
||||||
alignedDurationSeconds,
|
alignedDurationSeconds,
|
||||||
bumper.Width,
|
bumper.Width,
|
||||||
bumper.Height,
|
bumper.Height,
|
||||||
@@ -27,17 +23,10 @@ public static class BumperSpecFactory
|
|||||||
template.BackgroundColor2,
|
template.BackgroundColor2,
|
||||||
template.AccentColor,
|
template.AccentColor,
|
||||||
template.TextColor,
|
template.TextColor,
|
||||||
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
|
template.Font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
|
||||||
free ? "" : variant.NowLabel,
|
inputs.Lines,
|
||||||
free ? "" : inputs.FromName,
|
|
||||||
free ? "" : variant.NextLabel,
|
|
||||||
free ? "" : inputs.ToName,
|
|
||||||
inputs.BackgroundAbsolutePath,
|
inputs.BackgroundAbsolutePath,
|
||||||
inputs.AudioPath,
|
inputs.AudioPath,
|
||||||
inputs.PosterAbsolutePath,
|
inputs.PosterAbsolutePath
|
||||||
free,
|
|
||||||
variant.Line1,
|
|
||||||
variant.Line2
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их
|
/// Уже разрешённые входы рендера заставки: готовые строки (плейсхолдеры подставлены) и пути к
|
||||||
/// вызывающий (генератор эфира — по реальной паре соседей, превью — по образцам канала), а
|
/// файлам. Разрешает их вызывающий — генератор эфира по реальной паре соседей, редактор по
|
||||||
/// <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
|
/// образцам, — а <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record BumperSpecInputs(
|
public sealed record BumperSpecInputs(
|
||||||
string FromName,
|
IReadOnlyList<BumperRenderLine> Lines,
|
||||||
string ToName,
|
|
||||||
string? AudioPath = null,
|
string? AudioPath = null,
|
||||||
/// <summary>Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно.</summary>
|
/// <summary>Постер шоу как фон; подставляется, только если подблок его запросил.</summary>
|
||||||
string? PosterAbsolutePath = null,
|
string? PosterAbsolutePath = null,
|
||||||
string? BackgroundAbsolutePath = null
|
string? BackgroundAbsolutePath = null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ using TeleWave.Domain.Broadcast;
|
|||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил только
|
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил
|
||||||
/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку,
|
/// готовые строки и ссылку на блок, а рендеру нужны ещё абсолютные пути к звуку, постеру и фону.
|
||||||
/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения,
|
/// Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, воркер лишь крутит ffmpeg.
|
||||||
/// воркер лишь крутит ffmpeg.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BumperSpecLoader(
|
public sealed class BumperSpecLoader(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
@@ -38,28 +37,15 @@ public sealed class BumperSpecLoader(
|
|||||||
if (cache is null)
|
if (cache is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var channel = await dbContext
|
var template = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.BumperTemplates.AsNoTracking()
|
||||||
.Include(c => c.BumperTemplates)
|
.FirstOrDefaultAsync(t => t.Id == cache.TemplateId, cancellationToken);
|
||||||
.ThenInclude(t => t.Variants)
|
if (template is null)
|
||||||
.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)
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var names = await dbContext
|
var posterPath = cache.PosterShowId is { } showId
|
||||||
.Shows.AsNoTracking()
|
? await ResolveShowPosterAsync(showId, cancellationToken)
|
||||||
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
|
: null;
|
||||||
.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 bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
|
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
|
||||||
var aligned = BumperDuration.Aligned(
|
var aligned = BumperDuration.Aligned(
|
||||||
BumperDuration.TemplateSeconds(template),
|
BumperDuration.TemplateSeconds(template),
|
||||||
@@ -68,13 +54,10 @@ public sealed class BumperSpecLoader(
|
|||||||
|
|
||||||
return BumperSpecFactory.Build(
|
return BumperSpecFactory.Build(
|
||||||
_bumper,
|
_bumper,
|
||||||
channel.BumperFont,
|
|
||||||
template,
|
template,
|
||||||
variant,
|
|
||||||
aligned,
|
aligned,
|
||||||
new BumperSpecInputs(
|
new BumperSpecInputs(
|
||||||
names.GetValueOrDefault(cache.FromShowId, "…"),
|
BumperRenderedText.FromJson(cache.RenderedLinesJson),
|
||||||
names.GetValueOrDefault(cache.ToShowId, "…"),
|
|
||||||
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
||||||
posterPath,
|
posterPath,
|
||||||
bgPath
|
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 LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
@@ -15,19 +14,16 @@ public sealed class ClearBumperTemplateAudioCommandHandler(
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
);
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
template.ClearAudio();
|
template.ClearAudio();
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await storage.DeleteAudioAsync(template.Id, cancellationToken);
|
||||||
await storage.DeleteAudioAsync(command.TemplateId, cancellationToken);
|
|
||||||
return Result.Success();
|
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 LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
@@ -13,17 +12,14 @@ public sealed class ClearBumperTemplateBackgroundCommandHandler(IAppDbContext db
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
);
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
// Отвязываем фон; сама картинка остаётся в галерее.
|
|
||||||
template.ClearBackgroundImage();
|
template.ClearBackgroundImage();
|
||||||
return Result.Success();
|
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 LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using TeleWave.Application.Broadcast.Scheduling;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
@@ -9,6 +10,11 @@ using TeleWave.Domain.Programming;
|
|||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рендерит пример каждого подблока. Блок общий, поэтому образцы подстановки берутся глазами
|
||||||
|
/// выбранного канала: без него <c>{channel}</c> не на что заменить, а названия шоу были бы
|
||||||
|
/// случайными из библиотеки.
|
||||||
|
/// </summary>
|
||||||
public sealed class RenderBumperPreviewCommandHandler(
|
public sealed class RenderBumperPreviewCommandHandler(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IBumperRenderer renderer,
|
IBumperRenderer renderer,
|
||||||
@@ -21,56 +27,59 @@ public sealed class RenderBumperPreviewCommandHandler(
|
|||||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||||
|
|
||||||
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
|
|
||||||
private const int DefaultBumperDurationSeconds = 8;
|
|
||||||
|
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
RenderBumperPreviewCommand query,
|
RenderBumperPreviewCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.BumperTemplates.AsNoTracking()
|
||||||
.Include(c => c.BumperTemplates)
|
.Include(t => t.Variants)
|
||||||
.ThenInclude(t => t.Variants)
|
.FirstOrDefaultAsync(t => t.Id == command.TemplateId, cancellationToken);
|
||||||
.AsSplitQuery()
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
|
||||||
if (channel is null)
|
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
|
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
var channel = command.ChannelId is { } channelId
|
||||||
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
|
? await dbContext
|
||||||
var seconds = template.AudioDurationSeconds is { } d and > 0
|
.Channels.AsNoTracking()
|
||||||
? d
|
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken)
|
||||||
: DefaultBumperDurationSeconds;
|
: null;
|
||||||
var aligned = (int)(
|
|
||||||
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
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 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))
|
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(
|
var spec = BumperSpecFactory.Build(
|
||||||
_bumper,
|
_bumper,
|
||||||
channel.BumperFont,
|
|
||||||
template,
|
template,
|
||||||
variant,
|
|
||||||
aligned,
|
aligned,
|
||||||
inputs
|
new BumperSpecInputs(
|
||||||
|
lines,
|
||||||
|
audioPath,
|
||||||
|
variant.Background == BumperBackground.Template ? null : posterPath,
|
||||||
|
backgroundPath
|
||||||
|
)
|
||||||
);
|
);
|
||||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -78,49 +87,100 @@ public sealed class RenderBumperPreviewCommandHandler(
|
|||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
|
private static BumperContext BuildContext(Channel? channel, SampleShows samples)
|
||||||
private async Task<string?> ResolveBackgroundPathAsync(
|
|
||||||
BumperTemplate template,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (template.BackgroundImageId is not { } imageId)
|
var offset = TimeSpan.FromMinutes(
|
||||||
return null;
|
channel?.UtcOffsetMinutes ?? Channel.DefaultUtcOffsetMinutes
|
||||||
|
);
|
||||||
|
var moment = DateTimeOffset.UtcNow.ToOffset(offset);
|
||||||
|
|
||||||
var extension = await dbContext
|
return new BumperContext(
|
||||||
.Images.AsNoTracking()
|
channel?.Name ?? "Канал",
|
||||||
.Where(i => i.Id == imageId)
|
channel?.Number,
|
||||||
.Select(i => i.FileExtension)
|
moment,
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
samples.NowTitle,
|
||||||
|
samples.NextTitle,
|
||||||
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
|
"с1э5",
|
||||||
|
"с2э3",
|
||||||
|
samples.NextYear,
|
||||||
|
samples.NextGenre,
|
||||||
|
TimeOnly.FromDateTime(moment.AddMinutes(30).DateTime),
|
||||||
|
samples.SlotTitle
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
/// Пара шоу для образца. Берём те, что реально ходят в этом канале (через группы его слотов), —
|
||||||
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
/// иначе предпросмотр показывает библиотеку, а не канал.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<(string From, string To)> SampleNamesAsync(
|
private async Task<SampleShows> SampleShowsAsync(
|
||||||
Channel channel,
|
Channel? channel,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var names = await (
|
var query = dbContext.Shows.AsNoTracking().AsQueryable();
|
||||||
from slot in dbContext.Slots.AsNoTracking()
|
if (channel?.TemplateId is { } templateId)
|
||||||
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
query =
|
||||||
join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId
|
from show in query
|
||||||
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
|
join item in dbContext.GroupItems.AsNoTracking() on show.Id equals item.ElementId
|
||||||
where
|
join slot in dbContext.Slots.AsNoTracking() on item.GroupId equals slot.GroupId
|
||||||
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
|
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
||||||
select show.Name
|
where layer.TemplateId == templateId && item.ElementKind == GroupElementKind.Show
|
||||||
)
|
select show;
|
||||||
|
|
||||||
|
var shows = await query
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Name,
|
||||||
|
s.Year,
|
||||||
|
s.PosterImageId,
|
||||||
|
})
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Take(2)
|
.Take(2)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return (
|
var slotTitle = channel?.TemplateId is { } id
|
||||||
names.ElementAtOrDefault(0) ?? "Первое шоу",
|
? await (
|
||||||
names.ElementAtOrDefault(1) ?? "Второе шоу"
|
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 LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
@@ -13,15 +12,13 @@ public sealed class SetBumperTemplateAudioCommandHandler(IAppDbContext dbContext
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
);
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
template.SetAudio(command.Extension, command.DurationSeconds);
|
template.SetAudio(command.Extension, command.DurationSeconds);
|
||||||
return Result.Success();
|
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 Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Images;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
@@ -13,15 +14,16 @@ public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbCo
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
);
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
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);
|
template.SetBackgroundImage(command.ImageId);
|
||||||
return Result.Success();
|
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 LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
@@ -13,23 +12,15 @@ public sealed class UpdateBumperTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
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
|
|
||||||
);
|
);
|
||||||
|
if (template is null)
|
||||||
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
template.UpdateStyle(command.Style);
|
||||||
return Result.Success();
|
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);
|
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(
|
public sealed record ChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -46,9 +13,6 @@ public sealed record ChannelDto(
|
|||||||
int UtcOffsetMinutes,
|
int UtcOffsetMinutes,
|
||||||
TimeOnly DayStartTime,
|
TimeOnly DayStartTime,
|
||||||
Guid? TemplateId,
|
Guid? TemplateId,
|
||||||
bool BumpersEnabled,
|
|
||||||
BumperSettingsDto Bumper,
|
|
||||||
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
|
||||||
Guid? FillerAssetId,
|
Guid? FillerAssetId,
|
||||||
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
||||||
ViewerSettingsDto Viewer
|
ViewerSettingsDto Viewer
|
||||||
|
|||||||
@@ -17,44 +17,10 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
|||||||
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
||||||
var channel = await dbContext
|
var channel = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Include(c => c.BumperTemplates)
|
|
||||||
.ThenInclude(t => t.Variants)
|
|
||||||
.AsSplitQuery()
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
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(
|
return Result.Success(
|
||||||
new ChannelDto(
|
new ChannelDto(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
@@ -65,9 +31,6 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
|||||||
channel.UtcOffsetMinutes,
|
channel.UtcOffsetMinutes,
|
||||||
channel.DayStartTime,
|
channel.DayStartTime,
|
||||||
channel.TemplateId,
|
channel.TemplateId,
|
||||||
channel.BumpersEnabled,
|
|
||||||
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
|
|
||||||
bumperTemplates,
|
|
||||||
channel.FillerAssetId,
|
channel.FillerAssetId,
|
||||||
new ViewerSettingsDto(
|
new ViewerSettingsDto(
|
||||||
channel.LogoImageId,
|
channel.LogoImageId,
|
||||||
|
|||||||
+36
-45
@@ -1,5 +1,6 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
@@ -43,22 +44,31 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
|||||||
.Select(s => new { s.Id, s.Name })
|
.Select(s => new { s.Id, s.Name })
|
||||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||||
|
|
||||||
// Подблоки заставок в окне — чтобы показать в расписании, какая именно заставка и с каким текстом.
|
// Заставки в окне: берём их из кэша по ассету — там лежит ровно тот текст, который играл,
|
||||||
var variantIds = entries
|
// с уже подставленными плейсхолдерами. Собирать его заново из подблока значило бы гадать.
|
||||||
.Where(e =>
|
var bumperAssetIds = entries
|
||||||
e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper && e.BumperVariantId != null
|
.Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper)
|
||||||
)
|
.Select(e => e.MediaAssetId)
|
||||||
.Select(e => e.BumperVariantId!.Value)
|
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
var variants =
|
var bumpers =
|
||||||
variantIds.Count == 0
|
bumperAssetIds.Count == 0
|
||||||
? []
|
? []
|
||||||
: await dbContext
|
: await (
|
||||||
.BumperTextVariants.AsNoTracking()
|
from cache in dbContext.BumperAssets.AsNoTracking()
|
||||||
.Where(v => variantIds.Contains(v.Id))
|
join variant in dbContext.BumperTextVariants.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken);
|
on cache.VariantId equals variant.Id
|
||||||
var variantsById = variants.ToDictionary(v => v.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 в расписании админки.
|
// Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки.
|
||||||
var assetIds = entries
|
var assetIds = entries
|
||||||
@@ -72,21 +82,15 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
|||||||
.Select(a => new { a.Id, a.OriginalFileName })
|
.Select(a => new { a.Id, a.OriginalFileName })
|
||||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||||
|
|
||||||
// «Из какого шоу» для заставки берём из ближайшей предыдущей программы в упорядоченном окне.
|
|
||||||
Guid? prevProgramShowId = null;
|
|
||||||
var dtos = new List<ScheduleEntryDto>(entries.Count);
|
var dtos = new List<ScheduleEntryDto>(entries.Count);
|
||||||
foreach (var e in entries)
|
foreach (var e in entries)
|
||||||
{
|
{
|
||||||
string? bumperName = null;
|
string? bumperName = null;
|
||||||
string? bumperText = null;
|
string? bumperText = null;
|
||||||
if (
|
if (bumpersByAsset.TryGetValue(e.MediaAssetId, out var bumper))
|
||||||
e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper
|
|
||||||
&& e.BumperVariantId is { } vid
|
|
||||||
&& variantsById.TryGetValue(vid, out var variant)
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
bumperName = variant.Name;
|
bumperName = bumper.Name;
|
||||||
bumperText = BumperText(variant, prevProgramShowId, e.ShowId, showNames);
|
bumperText = BumperText(bumper.RenderedLinesJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
dtos.Add(
|
dtos.Add(
|
||||||
@@ -106,34 +110,21 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
|||||||
bumperText
|
bumperText
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
|
|
||||||
prevProgramShowId = e.ShowId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
|
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Строки сыгравшей заставки одной меткой для расписания; null — показывать нечего.</summary>
|
||||||
/// Текст заставки для метки в расписании: для «Сейчас/Далее» — подписи + названия шоу (из→в),
|
private static string? BumperText(string? renderedLinesJson)
|
||||||
/// для свободного текста — заданные строки. Возвращает null, если показывать нечего.
|
|
||||||
/// </summary>
|
|
||||||
private static string? BumperText(
|
|
||||||
Domain.Broadcast.BumperTextVariant variant,
|
|
||||||
Guid? fromShowId,
|
|
||||||
Guid? toShowId,
|
|
||||||
IReadOnlyDictionary<Guid, string> showNames
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (variant.Kind == Domain.Broadcast.BumperTextKind.Free)
|
var text = string.Join(
|
||||||
{
|
" · ",
|
||||||
var parts = new[] { variant.Line1, variant.Line2 }
|
BumperRenderedText
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
.FromJson(renderedLinesJson)
|
||||||
.ToArray();
|
.Select(l => l.Text)
|
||||||
return parts.Length == 0 ? null : string.Join(" · ", parts);
|
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||||
}
|
);
|
||||||
|
return text.Length == 0 ? null : text;
|
||||||
string Name(Guid? id) => id is { } g ? showNames.GetValueOrDefault(g, "…") : "…";
|
|
||||||
return $"{variant.NowLabel} {Name(fromShowId)} · {variant.NextLabel} {Name(toShowId)}";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-7
@@ -1,6 +1,5 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Broadcast;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
|
|
||||||
@@ -8,11 +7,5 @@ public sealed record UpdateChannelSettingsCommand(
|
|||||||
Guid ChannelId,
|
Guid ChannelId,
|
||||||
string Name,
|
string Name,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
bool BumpersEnabled,
|
|
||||||
BumperSettingsInput Bumper,
|
|
||||||
Guid? FillerAssetId
|
Guid? FillerAssetId
|
||||||
) : ICommand<Result>;
|
) : 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);
|
return Result.Failure(ChannelErrors.AssetNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.UpdateSettings(
|
channel.UpdateSettings(command.Name, command.IsEnabled, command.FillerAssetId);
|
||||||
command.Name,
|
|
||||||
command.IsEnabled,
|
|
||||||
command.BumpersEnabled,
|
|
||||||
command.FillerAssetId
|
|
||||||
);
|
|
||||||
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public interface IAppDbContext
|
|||||||
DbSet<JunctionElement> JunctionElements { get; }
|
DbSet<JunctionElement> JunctionElements { get; }
|
||||||
DbSet<Channel> Channels { get; }
|
DbSet<Channel> Channels { get; }
|
||||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||||
|
DbSet<BumperTemplate> BumperTemplates { get; }
|
||||||
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
||||||
DbSet<BumperAsset> BumperAssets { get; }
|
DbSet<BumperAsset> BumperAssets { get; }
|
||||||
DbSet<AppSetting> AppSettings { get; }
|
DbSet<AppSetting> AppSettings { get; }
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Common.Interfaces;
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Готовая строка заставки: роль, цвет из палитры блока и уже подставленный текст.</summary>
|
||||||
|
public sealed record BumperRenderLine(BumperLineStyle Style, BumperLineColor Color, string Text);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Полная спецификация одной заставки для рендера: оформление канала + подписи + названия шоу.
|
/// Полная спецификация одной заставки для рендера: оформление блока + готовые строки. Плейсхолдеры
|
||||||
|
/// в <see cref="Lines"/> уже подставлены — рендер работает с текстом, а не с шаблоном.
|
||||||
/// <see cref="DurationSeconds"/> уже выровнена на длину сегмента (готовит оркестратор), а
|
/// <see cref="DurationSeconds"/> уже выровнена на длину сегмента (готовит оркестратор), а
|
||||||
/// <see cref="FontFile"/> — абсолютный путь к TTF внутри контейнера.
|
/// <see cref="FontFile"/> — абсолютный путь к TTF внутри контейнера.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -14,18 +20,11 @@ public sealed record BumperRenderSpec(
|
|||||||
string AccentColor,
|
string AccentColor,
|
||||||
string TextColor,
|
string TextColor,
|
||||||
string FontFile,
|
string FontFile,
|
||||||
string NowLabel,
|
IReadOnlyList<BumperRenderLine> Lines,
|
||||||
string NowTitle,
|
|
||||||
string NextLabel,
|
|
||||||
string NextTitle,
|
|
||||||
string? BackgroundFile = null,
|
string? BackgroundFile = null,
|
||||||
string? MusicFile = null,
|
string? MusicFile = null,
|
||||||
/// <summary>Постер шоу как фон (используется, если нет загруженного фона канала; затемняется).</summary>
|
/// <summary>Постер шоу как фон (используется, если подблок его запросил; затемняется).</summary>
|
||||||
string? PosterFile = null,
|
string? PosterFile = null
|
||||||
/// <summary>Режим свободного текста: вместо «Сейчас/Далее» рисуются <see cref="FreeLine1"/>/<see cref="FreeLine2"/>.</summary>
|
|
||||||
bool FreeText = false,
|
|
||||||
string FreeLine1 = "",
|
|
||||||
string FreeLine2 = ""
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
||||||
@@ -39,9 +38,9 @@ public sealed record BumperRenderResult(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + текст
|
/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + строки текста
|
||||||
/// «Сейчас/Далее» + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в
|
/// + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в assets/{assetId} — так
|
||||||
/// assets/{assetId} — так же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
|
/// же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IBumperRenderer
|
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.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Broadcast.Scheduling;
|
using TeleWave.Domain.Broadcast.Scheduling;
|
||||||
@@ -10,21 +11,14 @@ using TeleWave.Domain.Programming.Planning;
|
|||||||
|
|
||||||
namespace TeleWave.Application.Programming.Planning;
|
namespace TeleWave.Application.Programming.Planning;
|
||||||
|
|
||||||
/// <summary>Ключ отрендеренной заставки: блок, подблок и пара шоу, между которыми она стоит.</summary>
|
|
||||||
public readonly record struct BumperKey(
|
|
||||||
Guid TemplateId,
|
|
||||||
Guid VariantId,
|
|
||||||
Guid FromShowId,
|
|
||||||
Guid ToShowId
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены
|
/// Подставляет ассеты заставкам, которые планировщик зарезервировал. Резерв и рендер разделены
|
||||||
/// намеренно: ассет зависит от пары соседей, а пара известна только после того, как слоты наполнены.
|
/// намеренно: текст заставки зависит от пары соседей и времени показа, а они известны только после
|
||||||
|
/// того, как слоты наполнены.
|
||||||
///
|
///
|
||||||
/// Готовый ассет переиспользуется по сигнатуре (пара названий + версия блока), недостающий
|
/// Готовый ассет переиспользуется по сигнатуре содержимого (оформление блока + подставленный текст),
|
||||||
/// регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру. Запись при
|
/// недостающий регистрируется в <see cref="MediaAssetStatus.Pending"/> и уходит фоновому рендереру.
|
||||||
/// этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
|
/// Запись при этом ставится в ленту сразу: горизонт — неделя, к эфиру рендер давно закончится.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BumperResolver(
|
public sealed class BumperResolver(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
@@ -32,120 +26,175 @@ public sealed class BumperResolver(
|
|||||||
IRandomSource random
|
IRandomSource random
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyDictionary<BumperKey, Guid>> ResolveAsync(
|
/// <summary>Ассеты заставок по индексу записи в ленте: одна и та же пара шоу может дать разный текст.</summary>
|
||||||
|
public async Task<IReadOnlyDictionary<int, Guid>> ResolveAsync(
|
||||||
Channel channel,
|
Channel channel,
|
||||||
IReadOnlyList<PlannedItem> items,
|
IReadOnlyList<PlannedItem> items,
|
||||||
CancellationToken cancellationToken
|
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)
|
if (reserved.Count == 0)
|
||||||
return new Dictionary<BumperKey, Guid>();
|
return new Dictionary<int, Guid>();
|
||||||
|
|
||||||
var showNames = await LoadShowNamesAsync(reserved, cancellationToken);
|
var templates = await LoadTemplatesAsync(reserved, cancellationToken);
|
||||||
var result = new Dictionary<BumperKey, Guid>();
|
if (templates.Count == 0)
|
||||||
|
return new Dictionary<int, Guid>();
|
||||||
|
|
||||||
// Кэш существующих заставок канала: одна пара шоу встречается в горизонте многократно.
|
var tokens = BumperPlaceholders.TokensIn(
|
||||||
var existing = await dbContext
|
templates
|
||||||
.BumperAssets.Where(b => b.ChannelId == channel.Id)
|
.Values.SelectMany(t => t.Variants)
|
||||||
.ToDictionaryAsync(b => b.Signature, b => b.MediaAssetId, cancellationToken);
|
.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 (
|
if (
|
||||||
item.BumperTemplateId is not { } templateId
|
item.BumperTemplateId is not { } templateId
|
||||||
|| channel.FindBumperTemplate(templateId) is not { } template
|
|| !templates.TryGetValue(templateId, out var template)
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var fromShowId = item.FromShowId ?? Guid.Empty;
|
var fromShowId = item.FromShowId ?? Guid.Empty;
|
||||||
var toShowId = item.ToShowId ?? 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)
|
if (variant is null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var key = new BumperKey(templateId, variant.Id, fromShowId, toShowId);
|
var context = facts.Context(item, index);
|
||||||
if (result.ContainsKey(key))
|
var lines = variant
|
||||||
continue;
|
.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 posterShowId = variant.Background switch
|
||||||
var toName = showNames.GetValueOrDefault(toShowId, "—");
|
|
||||||
var signature = Signature(template, variant.Id, fromName, toName);
|
|
||||||
|
|
||||||
if (existing.TryGetValue(signature, out var assetId))
|
|
||||||
{
|
{
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var asset = MediaAsset.RegisterGenerated($"{template.Name}: {fromName} → {toName}");
|
var asset = MediaAsset.RegisterGenerated(request.Caption);
|
||||||
dbContext.MediaAssets.Add(asset);
|
dbContext.MediaAssets.Add(asset);
|
||||||
dbContext.BumperAssets.Add(
|
dbContext.BumperAssets.Add(
|
||||||
BumperAsset.Create(
|
BumperAsset.Create(
|
||||||
channel.Id,
|
request.Template.Id,
|
||||||
templateId,
|
request.VariantId,
|
||||||
variant.Id,
|
request.Signature,
|
||||||
fromShowId,
|
request.Lines,
|
||||||
toShowId,
|
request.PosterShowId,
|
||||||
signature,
|
|
||||||
asset.Id
|
asset.Id
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
existing[signature] = asset.Id;
|
existing[request.Signature] = asset.Id;
|
||||||
result[key] = asset.Id;
|
result[index] = asset.Id;
|
||||||
renderQueue.Enqueue(asset.Id);
|
renderQueue.Enqueue(asset.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task<Dictionary<Guid, BumperTemplate>> LoadTemplatesAsync(
|
||||||
/// Подблок, подходящий под контекст перехода: на смене шоу и между сериями одного играют разные
|
IReadOnlyList<(PlannedItem Item, int Index)> reserved,
|
||||||
/// тексты. Стратегия выбора — общая настройка канала.
|
CancellationToken cancellationToken
|
||||||
/// </summary>
|
|
||||||
private static BumperTextVariant? PickVariant(
|
|
||||||
BumperTemplate template,
|
|
||||||
bool isShowChange,
|
|
||||||
Channel channel,
|
|
||||||
IRandomSource random
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var eligible = template
|
var ids = reserved
|
||||||
.Variants.Where(v =>
|
.Select(r => r.Item.BumperTemplateId)
|
||||||
v.Trigger switch
|
.Where(id => id is not null)
|
||||||
{
|
.Select(id => id!.Value)
|
||||||
BumperTrigger.OnShowChange => isShowChange,
|
.Distinct()
|
||||||
BumperTrigger.BetweenEpisodes => !isShowChange,
|
|
||||||
_ => true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.OrderBy(v => v.Position)
|
|
||||||
.ToList();
|
.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)
|
if (eligible.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return channel.BumperSelection switch
|
var total = eligible.Sum(v => Math.Max(0, v.Weight));
|
||||||
{
|
|
||||||
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));
|
|
||||||
if (total <= 0)
|
if (total <= 0)
|
||||||
return eligible[random.Next(eligible.Count)];
|
return eligible[random.Next(eligible.Count)];
|
||||||
|
|
||||||
var roll = random.Next((int)Math.Min(total, int.MaxValue));
|
var roll = random.Next(total);
|
||||||
long accumulated = 0;
|
var accumulated = 0;
|
||||||
foreach (var variant in eligible)
|
foreach (var variant in eligible)
|
||||||
{
|
{
|
||||||
accumulated += Math.Max(0, variant.Weight);
|
accumulated += Math.Max(0, variant.Weight);
|
||||||
@@ -157,41 +206,44 @@ public sealed class BumperResolver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сигнатура включает версию блока: замена звука или фона обязана пересобрать заставки, иначе
|
/// Сигнатура — хэш содержимого: оформление блока с его ревизией плюс подставленный текст. Канала
|
||||||
/// в эфире осталась бы старая картинка с новым оформлением рядом.
|
/// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а
|
||||||
|
/// <c>{channel}</c> в тексте разводит их сам собой.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string Signature(
|
private static string Signature(
|
||||||
BumperTemplate template,
|
BumperTemplate template,
|
||||||
Guid variantId,
|
Guid variantId,
|
||||||
string fromName,
|
string linesJson,
|
||||||
string toName
|
Guid? posterShowId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var raw = string.Create(
|
var raw = string.Create(
|
||||||
CultureInfo.InvariantCulture,
|
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];
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
/// <summary>Что нужно отрендерить для одной записи ленты.</summary>
|
||||||
IReadOnlyList<PlannedItem> reserved,
|
private sealed record BumperRequest(
|
||||||
CancellationToken cancellationToken
|
BumperTemplate Template,
|
||||||
|
Guid VariantId,
|
||||||
|
string Lines,
|
||||||
|
Guid? PosterShowId,
|
||||||
|
string Signature
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var showIds = reserved
|
/// <summary>Имя ассета для админки: блок и первые строки заставки.</summary>
|
||||||
.SelectMany(i => new[] { i.FromShowId, i.ToShowId })
|
public string Caption
|
||||||
.Where(id => id is not null && id != Guid.Empty)
|
{
|
||||||
.Select(id => id!.Value)
|
get
|
||||||
.Distinct()
|
{
|
||||||
.ToList();
|
var text = string.Join(
|
||||||
|
" / ",
|
||||||
if (showIds.Count == 0)
|
BumperRenderedText.FromJson(Lines).Select(l => l.Text).Take(2)
|
||||||
return [];
|
);
|
||||||
|
return text.Length == 0 ? Template.Name : $"{Template.Name}: {text}";
|
||||||
return await dbContext
|
}
|
||||||
.Shows.AsNoTracking()
|
}
|
||||||
.Where(s => showIds.Contains(s.Id))
|
|
||||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,14 +57,10 @@ public sealed class GridScheduleGenerator(
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
c => c.Id == channelId,
|
||||||
.ThenInclude(t => t.Variants)
|
cancellationToken
|
||||||
// Вложенные коллекции тянем отдельными запросами: иначе колонки родителя (у шаблона —
|
);
|
||||||
// jsonb с правилами, у слоя — jsonb применимости) приезжают по копии на каждую строку
|
|
||||||
// листа. Тик планировщика повторяет эти два запроса на каждый канал.
|
|
||||||
.AsSplitQuery()
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
|
||||||
if (channel is null || !channel.IsEnabled || channel.TemplateId is null)
|
if (channel is null || !channel.IsEnabled || channel.TemplateId is null)
|
||||||
return new GenerationReport(0, [], ChannelSkipped: true);
|
return new GenerationReport(0, [], ChannelSkipped: true);
|
||||||
|
|
||||||
@@ -129,12 +125,13 @@ public sealed class GridScheduleGenerator(
|
|||||||
);
|
);
|
||||||
|
|
||||||
var added = 0;
|
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;
|
var assetId = item.MediaAssetId;
|
||||||
if (
|
if (
|
||||||
item.Kind == PlannedItemKind.Bumper
|
item.Kind == PlannedItemKind.Bumper
|
||||||
&& !TryResolveBumper(item, bumperAssets, out assetId)
|
&& !bumperAssets.TryGetValue(index, out assetId)
|
||||||
)
|
)
|
||||||
continue; // Без ассета запись стала бы дырой в ленте.
|
continue; // Без ассета запись стала бы дырой в ленте.
|
||||||
|
|
||||||
@@ -184,9 +181,6 @@ public sealed class GridScheduleGenerator(
|
|||||||
{
|
{
|
||||||
var channel = await dbContext
|
var channel = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Include(c => c.BumperTemplates)
|
|
||||||
.ThenInclude(t => t.Variants)
|
|
||||||
.AsSplitQuery()
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||||
if (channel is null || channel.TemplateId is null)
|
if (channel is null || channel.TemplateId is null)
|
||||||
return null;
|
return null;
|
||||||
@@ -280,13 +274,17 @@ public sealed class GridScheduleGenerator(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
|
// Стыки грузим целиком: их немного, а группы врезок надо развернуть тем же проходом,
|
||||||
// что и группы контента.
|
// что и группы контента. Стыки общие, поэтому фильтра по каналу нет.
|
||||||
var junctions = await dbContext
|
var junctions = await dbContext
|
||||||
.JunctionTemplates.AsNoTracking()
|
.JunctionTemplates.AsNoTracking()
|
||||||
.Include(j => j.Elements)
|
.Include(j => j.Elements)
|
||||||
.Where(j => j.ChannelId == channel.Id)
|
|
||||||
.ToDictionaryAsync(j => j.Id, cancellationToken);
|
.ToDictionaryAsync(j => j.Id, cancellationToken);
|
||||||
|
|
||||||
|
// Блоки заставок нужны только длительностью — текст подставит резолвер после сборки ленты.
|
||||||
|
var bumperTemplates = await dbContext
|
||||||
|
.BumperTemplates.AsNoTracking()
|
||||||
|
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||||
|
|
||||||
var groupIds = scheduled
|
var groupIds = scheduled
|
||||||
.Select(s => s.Slot.GroupId)
|
.Select(s => s.Slot.GroupId)
|
||||||
.Where(id => id is not null)
|
.Where(id => id is not null)
|
||||||
@@ -361,12 +359,19 @@ public sealed class GridScheduleGenerator(
|
|||||||
elements,
|
elements,
|
||||||
cursor,
|
cursor,
|
||||||
repeatUnits,
|
repeatUnits,
|
||||||
BuildJunction(slot.JunctionBetweenId, junctions, elementsByGroup, channel),
|
BuildJunction(
|
||||||
|
slot.JunctionBetweenId,
|
||||||
|
junctions,
|
||||||
|
elementsByGroup,
|
||||||
|
bumperTemplates,
|
||||||
|
slot.Daypart
|
||||||
|
),
|
||||||
BuildJunction(
|
BuildJunction(
|
||||||
slot.JunctionAfterId ?? template.DefaultJunctionId,
|
slot.JunctionAfterId ?? template.DefaultJunctionId,
|
||||||
junctions,
|
junctions,
|
||||||
elementsByGroup,
|
elementsByGroup,
|
||||||
channel
|
bumperTemplates,
|
||||||
|
slot.Daypart
|
||||||
),
|
),
|
||||||
rules?.AudienceAt(
|
rules?.AudienceAt(
|
||||||
TimeOnly.FromDateTime(
|
TimeOnly.FromDateTime(
|
||||||
@@ -388,44 +393,18 @@ public sealed class GridScheduleGenerator(
|
|||||||
horizonEnd,
|
horizonEnd,
|
||||||
slots,
|
slots,
|
||||||
fallback,
|
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>
|
/// <summary>Разворачивает шаблон стыка для планировщика, включая резерв под заставки.</summary>
|
||||||
private PlanningJunction? BuildJunction(
|
private PlanningJunction? BuildJunction(
|
||||||
Guid? junctionId,
|
Guid? junctionId,
|
||||||
IReadOnlyDictionary<Guid, JunctionTemplate> junctions,
|
IReadOnlyDictionary<Guid, JunctionTemplate> junctions,
|
||||||
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
|
IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>> elementsByGroup,
|
||||||
Channel channel
|
IReadOnlyDictionary<Guid, BumperTemplate> bumperTemplates,
|
||||||
|
Daypart daypart
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (junctionId is not { } id || !junctions.TryGetValue(id, out var template))
|
if (junctionId is not { } id || !junctions.TryGetValue(id, out var template))
|
||||||
@@ -437,57 +416,83 @@ public sealed class GridScheduleGenerator(
|
|||||||
var conditions =
|
var conditions =
|
||||||
JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions();
|
JunctionConditions.FromJson(element.ConditionsJson) ?? new JunctionConditions();
|
||||||
|
|
||||||
if (element.Kind == JunctionElementKind.Bumper)
|
// Дейпарт — свойство слота, а не момента: отсекаем здесь, чтобы домен не знал про сетку.
|
||||||
{
|
if (!conditions.AllowsDaypart(daypart))
|
||||||
// Длительность задаётся блоком (по звуку) и выровнена на сегмент: планировщик
|
|
||||||
// резервирует именно её, ассет подставит резолвер после сборки ленты.
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
var units = ResolveUnits(element, elementsByGroup, bumperTemplates, out var bumper);
|
||||||
element.GroupId is not { } groupId
|
if (units is null)
|
||||||
|| !elementsByGroup.TryGetValue(groupId, out var groupElements)
|
|
||||||
)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
elements.Add(
|
elements.Add(
|
||||||
new PlanningJunctionElement(
|
new PlanningJunctionElement(
|
||||||
|
element.Id,
|
||||||
element.Kind,
|
element.Kind,
|
||||||
groupElements.SelectMany(e => e.Units).ToList(),
|
units,
|
||||||
element.AmountMode,
|
element.AmountMode,
|
||||||
element.AmountValue,
|
element.AmountValue,
|
||||||
element.IsRequired,
|
element.IsRequired,
|
||||||
conditions.OnlyOnElementChange,
|
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>
|
/// <summary>
|
||||||
|
|||||||
+5
-12
@@ -5,22 +5,15 @@ using TeleWave.Application.Common.Models;
|
|||||||
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
|
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Копирует сетку канала на другой канал: слои, слоты, стыки и правила. Группы не копируются —
|
/// Копирует сетку канала на другой канал: слои, слоты и правила. Группы, стыки и заставки
|
||||||
/// они общие для всех каналов. Прежний шаблон канала-приёмника заменяется целиком.
|
/// не копируются — они общие для всех каналов, копия ссылается на те же. Прежний шаблон
|
||||||
|
/// канала-приёмника заменяется целиком.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId)
|
public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId)
|
||||||
: ICommand<Result<CopyTemplateResultDto>>;
|
: ICommand<Result<CopyTemplateResultDto>>;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Что скопировалось.</summary>
|
||||||
/// Что скопировалось. <paramref name="DroppedBumperRefs"/> — врезки-заставки, для которых на канале
|
public sealed record CopyTemplateResultDto(int Layers, int Slots);
|
||||||
/// -приёмнике не нашлось блока с таким же именем: ссылка снята, врезку надо донастроить руками.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CopyTemplateResultDto(
|
|
||||||
int Layers,
|
|
||||||
int Slots,
|
|
||||||
int Junctions,
|
|
||||||
int DroppedBumperRefs
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed class CopyTemplateCommandValidator : AbstractValidator<CopyTemplateCommand>
|
public sealed class CopyTemplateCommandValidator : AbstractValidator<CopyTemplateCommand>
|
||||||
{
|
{
|
||||||
|
|||||||
+13
-126
@@ -15,9 +15,10 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var target = await dbContext
|
var target = await dbContext.Channels.FirstOrDefaultAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
c => c.Id == command.TargetChannelId,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
|
cancellationToken
|
||||||
|
);
|
||||||
if (target is null)
|
if (target is null)
|
||||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
|
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
@@ -30,138 +31,31 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
if (source is null)
|
if (source is null)
|
||||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
|
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
|
var existing = await dbContext
|
||||||
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
|
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
dbContext.ScheduleTemplates.RemoveRange(existing);
|
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);
|
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
|
||||||
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
|
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
|
||||||
copyTemplate.SetRules(source.RulesJson);
|
copyTemplate.SetRules(source.RulesJson);
|
||||||
if (
|
// Стыки и заставки общие для всех каналов — копия ссылается на те же, без перевешивания.
|
||||||
source.DefaultJunctionId is { } defaultJunction
|
copyTemplate.SetDefaultJunction(source.DefaultJunctionId);
|
||||||
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
|
|
||||||
)
|
|
||||||
copyTemplate.SetDefaultJunction(mappedDefault);
|
|
||||||
|
|
||||||
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
|
var (layers, slots) = CopyGrid(source, copyTemplate);
|
||||||
|
|
||||||
dbContext.ScheduleTemplates.Add(copyTemplate);
|
dbContext.ScheduleTemplates.Add(copyTemplate);
|
||||||
target.SetTemplate(copyTemplate.Id);
|
target.SetTemplate(copyTemplate.Id);
|
||||||
|
|
||||||
return Result.Success(
|
return Result.Success(new CopyTemplateResultDto(layers, slots));
|
||||||
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
|
|
||||||
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
|
|
||||||
/// </summary>
|
|
||||||
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
|
|
||||||
IReadOnlyList<JunctionTemplate> sourceJunctions,
|
|
||||||
Guid targetChannelId,
|
|
||||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
|
||||||
IReadOnlyDictionary<string, Guid> targetBumperByName
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var map = new Dictionary<Guid, Guid>();
|
|
||||||
var dropped = 0;
|
|
||||||
|
|
||||||
foreach (var junction in sourceJunctions)
|
|
||||||
{
|
|
||||||
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
|
|
||||||
map[junction.Id] = copy.Id;
|
|
||||||
|
|
||||||
foreach (var element in junction.Elements.OrderBy(e => e.Position))
|
|
||||||
{
|
|
||||||
var bumperTemplateId = MapBumper(
|
|
||||||
element,
|
|
||||||
sourceBumperNames,
|
|
||||||
targetBumperByName,
|
|
||||||
ref dropped
|
|
||||||
);
|
|
||||||
copy.AddElement(element.Kind)
|
|
||||||
.Update(
|
|
||||||
element.Kind,
|
|
||||||
element.GroupId,
|
|
||||||
bumperTemplateId,
|
|
||||||
element.AmountMode,
|
|
||||||
element.AmountValue,
|
|
||||||
element.IsRequired,
|
|
||||||
element.ConditionsJson
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
dbContext.JunctionTemplates.Add(copy);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (map, dropped);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
|
|
||||||
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
|
|
||||||
/// и это попадает в отчёт.
|
|
||||||
/// </summary>
|
|
||||||
private static Guid? MapBumper(
|
|
||||||
JunctionElement element,
|
|
||||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
|
||||||
IReadOnlyDictionary<string, Guid> targetBumperByName,
|
|
||||||
ref int dropped
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (element.Kind != JunctionElementKind.Bumper)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
element.BumperTemplateId is { } sourceId
|
|
||||||
&& sourceBumperNames.TryGetValue(sourceId, out var name)
|
|
||||||
&& targetBumperByName.TryGetValue(name, out var mapped)
|
|
||||||
)
|
|
||||||
return mapped;
|
|
||||||
|
|
||||||
dropped++;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
|
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
|
||||||
private static (int Layers, int Slots) CopyGrid(
|
private static (int Layers, int Slots) CopyGrid(
|
||||||
ScheduleTemplate source,
|
ScheduleTemplate source,
|
||||||
ScheduleTemplate copyTemplate,
|
ScheduleTemplate copyTemplate
|
||||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var layers = 0;
|
var layers = 0;
|
||||||
@@ -179,7 +73,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
|
|
||||||
foreach (var slot in layer.Slots)
|
foreach (var slot in layer.Slots)
|
||||||
{
|
{
|
||||||
CopySlot(slot, copyLayer, junctionMap);
|
CopySlot(slot, copyLayer);
|
||||||
slots++;
|
slots++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,11 +81,7 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
return (layers, slots);
|
return (layers, slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CopySlot(
|
private static void CopySlot(Slot slot, GridLayer copyLayer)
|
||||||
Slot slot,
|
|
||||||
GridLayer copyLayer,
|
|
||||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var copySlot = copyLayer.AddSlot(
|
var copySlot = copyLayer.AddSlot(
|
||||||
slot.Title,
|
slot.Title,
|
||||||
@@ -220,12 +110,9 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
slot.BlockMode,
|
slot.BlockMode,
|
||||||
slot.BlockValue,
|
slot.BlockValue,
|
||||||
slot.OverflowPolicy,
|
slot.OverflowPolicy,
|
||||||
Map(slot.JunctionBetweenId, junctionMap),
|
slot.JunctionBetweenId,
|
||||||
Map(slot.JunctionAfterId, junctionMap)
|
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;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates;
|
namespace TeleWave.Application.Programming.Templates;
|
||||||
|
|
||||||
@@ -11,7 +12,13 @@ public sealed record JunctionConditions(
|
|||||||
/// <summary>Ставить только при смене шоу, а не между сериями одного.</summary>
|
/// <summary>Ставить только при смене шоу, а не между сериями одного.</summary>
|
||||||
bool OnlyOnElementChange = false,
|
bool OnlyOnElementChange = false,
|
||||||
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
/// <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()
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
@@ -21,6 +28,10 @@ public sealed record JunctionConditions(
|
|||||||
Converters = { new JsonStringEnumConverter() },
|
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 string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||||
|
|
||||||
public static JunctionConditions? FromJson(string? json)
|
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);
|
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
|
||||||
|
|
||||||
var element = junction.AddElement(command.Kind);
|
var element = junction.AddElement(command.Kind);
|
||||||
await JunctionLoader.MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
|
await JunctionLoader.MarkTemplatesChangedAsync(dbContext, junction.Id, cancellationToken);
|
||||||
return Result.Success(element.Id);
|
return Result.Success(element.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-8
@@ -1,6 +1,4 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Broadcast;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
@@ -10,16 +8,13 @@ namespace TeleWave.Application.Programming.Templates.Junctions;
|
|||||||
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
|
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
|
||||||
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
|
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
|
||||||
{
|
{
|
||||||
public async Task<Result<Guid>> Handle(
|
public Task<Result<Guid>> Handle(
|
||||||
CreateJunctionCommand command,
|
CreateJunctionCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
|
var junction = JunctionTemplate.Create(command.Name);
|
||||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
|
|
||||||
dbContext.JunctionTemplates.Add(junction);
|
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)
|
if (junction is null)
|
||||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
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,
|
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (used)
|
var usedByDefault = await dbContext.ScheduleTemplates.AnyAsync(
|
||||||
|
t => t.DefaultJunctionId == junction.Id,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (usedBySlot || usedByDefault)
|
||||||
return Result.Failure(TemplateErrors.JunctionInUse);
|
return Result.Failure(TemplateErrors.JunctionInUse);
|
||||||
|
|
||||||
dbContext.JunctionTemplates.Remove(junction);
|
dbContext.JunctionTemplates.Remove(junction);
|
||||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
return Result.Success();
|
||||||
dbContext,
|
|
||||||
junction,
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-8
@@ -5,12 +5,12 @@ using TeleWave.Domain.Programming;
|
|||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||||
|
|
||||||
public sealed record ListJunctionsQuery(Guid ChannelId)
|
public sealed record ListJunctionsQuery : IQuery<IReadOnlyList<JunctionTemplateDto>>;
|
||||||
: 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>;
|
public sealed record DeleteJunctionCommand(Guid JunctionId) : ICommand<Result>;
|
||||||
|
|
||||||
@@ -22,9 +22,13 @@ public sealed record JunctionElementInput(
|
|||||||
JunctionElementKind Kind,
|
JunctionElementKind Kind,
|
||||||
Guid? GroupId,
|
Guid? GroupId,
|
||||||
Guid? BumperTemplateId,
|
Guid? BumperTemplateId,
|
||||||
|
Guid? BumperVariantId,
|
||||||
JunctionAmountMode AmountMode,
|
JunctionAmountMode AmountMode,
|
||||||
int AmountValue,
|
int AmountValue,
|
||||||
bool IsRequired,
|
bool IsRequired,
|
||||||
|
/// <summary>Метка развилки: из врезок с одной меткой играет одна, выбранная по весам.</summary>
|
||||||
|
string? ChoiceKey,
|
||||||
|
int ChoiceWeight,
|
||||||
JunctionConditions? Conditions
|
JunctionConditions? Conditions
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -37,17 +41,32 @@ public sealed record UpdateJunctionElementCommand(
|
|||||||
public sealed record RemoveJunctionElementCommand(Guid JunctionId, Guid ElementId)
|
public sealed record RemoveJunctionElementCommand(Guid JunctionId, Guid ElementId)
|
||||||
: ICommand<Result>;
|
: ICommand<Result>;
|
||||||
|
|
||||||
public sealed record ReorderJunctionCommand(Guid JunctionId, IReadOnlyList<Guid> ElementIdsInOrder)
|
/// <summary>
|
||||||
: ICommand<Result>;
|
/// Позиция врезки вместе с её развилкой: перетаскивание в цепочке одновременно меняет и порядок,
|
||||||
|
/// и принадлежность к развилке, поэтому отдельной команды «сгруппировать» нет.
|
||||||
|
/// </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 sealed class CreateJunctionCommandValidator : AbstractValidator<CreateJunctionCommand>
|
||||||
{
|
{
|
||||||
public CreateJunctionCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
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
|
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.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)
|
RuleFor(x => x.Input.Conditions!.MinMinutesBetween)
|
||||||
.InclusiveBetween(0, 24 * 60)
|
.InclusiveBetween(0, 24 * 60)
|
||||||
.When(x => x.Input.Conditions is not null);
|
.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,
|
string? GroupName,
|
||||||
Guid? BumperTemplateId,
|
Guid? BumperTemplateId,
|
||||||
string? BumperTemplateName,
|
string? BumperTemplateName,
|
||||||
|
Guid? BumperVariantId,
|
||||||
|
string? BumperVariantName,
|
||||||
JunctionAmountMode AmountMode,
|
JunctionAmountMode AmountMode,
|
||||||
int AmountValue,
|
int AmountValue,
|
||||||
bool IsRequired,
|
bool IsRequired,
|
||||||
|
string? ChoiceKey,
|
||||||
|
int ChoiceWeight,
|
||||||
JunctionConditions? Conditions
|
JunctionConditions? Conditions
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record JunctionTemplateDto(
|
public sealed record JunctionTemplateDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
|
int? MaxTotalSeconds,
|
||||||
|
/// <summary>Сколько каналов ссылается на стык — он общий, и это надо видеть до правки.</summary>
|
||||||
|
int ChannelUsageCount,
|
||||||
IReadOnlyList<JunctionElementDto> Elements
|
IReadOnlyList<JunctionElementDto> Elements
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,18 +17,37 @@ internal static class JunctionLoader
|
|||||||
.JunctionTemplates.Include(j => j.Elements)
|
.JunctionTemplates.Include(j => j.Elements)
|
||||||
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
|
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
|
||||||
|
|
||||||
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
|
/// <summary>
|
||||||
public static async Task<Result> MarkTemplateChangedAsync(
|
/// Правка стыка — правка правил эфира. Стык общий, поэтому изменёнными помечаются все шаблоны,
|
||||||
|
/// которые на него ссылаются: иначе чужой канал молча поехал бы по новым врезкам без применения.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<Result> MarkTemplatesChangedAsync(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
JunctionTemplate junction,
|
Guid junctionId,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
|
var viaSlots = await dbContext
|
||||||
t => t.ChannelId == junction.ChannelId,
|
.Slots.AsNoTracking()
|
||||||
cancellationToken
|
.Where(s => s.JunctionBetweenId == junctionId || s.JunctionAfterId == junctionId)
|
||||||
);
|
.Join(
|
||||||
template?.MarkChanged();
|
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();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-20
@@ -15,53 +15,102 @@ public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
|
|||||||
var junctions = await dbContext
|
var junctions = await dbContext
|
||||||
.JunctionTemplates.AsNoTracking()
|
.JunctionTemplates.AsNoTracking()
|
||||||
.Include(j => j.Elements)
|
.Include(j => j.Elements)
|
||||||
.Where(j => j.ChannelId == query.ChannelId)
|
|
||||||
.OrderBy(j => j.Name)
|
.OrderBy(j => j.Name)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
|
// Имена групп и заставок резолвим одним проходом — редактор показывает их сразу.
|
||||||
var groupIds = junctions
|
var groupIds = Ids(junctions, e => e.GroupId);
|
||||||
.SelectMany(j => j.Elements)
|
|
||||||
.Select(e => e.GroupId)
|
|
||||||
.Where(id => id is not null)
|
|
||||||
.Select(id => id!.Value)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
var groupNames = await dbContext
|
var groupNames = await dbContext
|
||||||
.Groups.AsNoTracking()
|
.Groups.AsNoTracking()
|
||||||
.Where(g => groupIds.Contains(g.Id))
|
.Where(g => groupIds.Contains(g.Id))
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||||
|
|
||||||
var bumperNames = await dbContext
|
var templateIds = Ids(junctions, e => e.BumperTemplateId);
|
||||||
.Channels.AsNoTracking()
|
var bumpers = await dbContext
|
||||||
.Where(c => c.Id == query.ChannelId)
|
.BumperTemplates.AsNoTracking()
|
||||||
.SelectMany(c => c.BumperTemplates)
|
.Where(t => templateIds.Contains(t.Id))
|
||||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
.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
|
return junctions
|
||||||
.Select(j => new JunctionTemplateDto(
|
.Select(j => new JunctionTemplateDto(
|
||||||
j.Id,
|
j.Id,
|
||||||
j.Name,
|
j.Name,
|
||||||
|
j.MaxTotalSeconds,
|
||||||
|
usage.GetValueOrDefault(j.Id),
|
||||||
j.Elements.OrderBy(e => e.Position)
|
j.Elements.OrderBy(e => e.Position)
|
||||||
.Select(e => new JunctionElementDto(
|
.Select(e => new JunctionElementDto(
|
||||||
e.Id,
|
e.Id,
|
||||||
e.Position,
|
e.Position,
|
||||||
e.Kind,
|
e.Kind,
|
||||||
e.GroupId,
|
e.GroupId,
|
||||||
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
|
Lookup(groupNames, e.GroupId),
|
||||||
? gname
|
|
||||||
: null,
|
|
||||||
e.BumperTemplateId,
|
e.BumperTemplateId,
|
||||||
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
|
Lookup(bumperNames, e.BumperTemplateId),
|
||||||
? bname
|
e.BumperVariantId,
|
||||||
: null,
|
Lookup(variantNames, e.BumperVariantId),
|
||||||
e.AmountMode,
|
e.AmountMode,
|
||||||
e.AmountValue,
|
e.AmountValue,
|
||||||
e.IsRequired,
|
e.IsRequired,
|
||||||
|
e.ChoiceKey,
|
||||||
|
e.ChoiceWeight,
|
||||||
JunctionConditions.FromJson(e.ConditionsJson)
|
JunctionConditions.FromJson(e.ConditionsJson)
|
||||||
))
|
))
|
||||||
.ToList()
|
.ToList()
|
||||||
))
|
))
|
||||||
.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))
|
if (junction is null || !junction.RemoveElement(command.ElementId))
|
||||||
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
||||||
|
|
||||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
return await JunctionLoader.MarkTemplatesChangedAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
junction,
|
junction.Id,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -20,10 +20,15 @@ public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
|
|||||||
if (junction is null)
|
if (junction is null)
|
||||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
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,
|
dbContext,
|
||||||
junction,
|
junction.Id,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -4,11 +4,11 @@ using TeleWave.Application.Common.Models;
|
|||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.Junctions;
|
namespace TeleWave.Application.Programming.Templates.Junctions;
|
||||||
|
|
||||||
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
|
public sealed class UpdateJunctionCommandHandler(IAppDbContext dbContext)
|
||||||
: ICommandHandler<RenameJunctionCommand, Result>
|
: ICommandHandler<UpdateJunctionCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
RenameJunctionCommand command,
|
UpdateJunctionCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -20,10 +20,10 @@ public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
|
|||||||
if (junction is null)
|
if (junction is null)
|
||||||
return Result.Failure(TemplateErrors.JunctionNotFound);
|
return Result.Failure(TemplateErrors.JunctionNotFound);
|
||||||
|
|
||||||
junction.Rename(command.Name);
|
junction.Update(command.Name, command.MaxTotalSeconds);
|
||||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
return await JunctionLoader.MarkTemplatesChangedAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
junction,
|
junction.Id,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+49
-27
@@ -1,6 +1,6 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
@@ -25,38 +25,60 @@ public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
|
|||||||
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
return Result.Failure(TemplateErrors.JunctionElementNotFound);
|
||||||
|
|
||||||
var input = command.Input;
|
var input = command.Input;
|
||||||
|
var check = await ValidateSourceAsync(input, cancellationToken);
|
||||||
if (input.Kind == JunctionElementKind.Bumper)
|
if (!check.IsSuccess)
|
||||||
{
|
return check;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.Update(
|
element.Update(
|
||||||
input.Kind,
|
new JunctionElementSettings(
|
||||||
input.GroupId,
|
input.Kind,
|
||||||
input.BumperTemplateId,
|
input.GroupId,
|
||||||
input.AmountMode,
|
input.BumperTemplateId,
|
||||||
input.AmountValue,
|
input.BumperVariantId,
|
||||||
input.IsRequired,
|
input.AmountMode,
|
||||||
input.Conditions?.ToJson()
|
input.AmountValue,
|
||||||
|
input.IsRequired,
|
||||||
|
input.ChoiceKey,
|
||||||
|
input.ChoiceWeight,
|
||||||
|
input.Conditions?.ToJson()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return await JunctionLoader.MarkTemplateChangedAsync(
|
return await JunctionLoader.MarkTemplatesChangedAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
junction,
|
junction.Id,
|
||||||
cancellationToken
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Кэш ТВ-заставки перехода. Один сгенерированный <see cref="MediaAssetId"/> на уникальную комбинацию
|
/// Кэш отрендеренной ТВ-заставки. Ключ — <see cref="Signature"/>, хэш **содержимого**: оформление
|
||||||
/// (<see cref="FromShowId"/> → <see cref="ToShowId"/>) при данной <see cref="Signature"/> (хэш названий
|
/// блока с его ревизией плюс уже подставленный текст. Ни канала, ни пары шоу в ключе нет намеренно:
|
||||||
/// шоу и версии шаблона). Ассет создаётся в статусе Pending и рендерится ffmpeg'ом асинхронно фоновым
|
/// одинаковая заставка на трёх каналах рендерится один раз, а плейсхолдер вроде <c>{channel}</c>
|
||||||
/// сервисом — поэтому здесь же храним, ЧЕМ его рендерить (<see cref="ChannelId"/>/<see cref="TemplateId"/>/
|
/// разводит их по разным сигнатурам сам собой.
|
||||||
/// <see cref="VariantId"/>), чтобы фоновый рендерер восстановил спецификацию без участия планировщика.
|
///
|
||||||
/// Переиспользуется между днями и каналами; при смене названий/шаблона сигнатура меняется — новый ассет.
|
/// Подставленный текст (<see cref="RenderedLinesJson"/>) приходится хранить: время показа из ссылок
|
||||||
|
/// задним числом не восстанавливается, а ffmpeg запускается фоновым сервисом уже после того, как
|
||||||
|
/// лента записана.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BumperAsset
|
public class BumperAsset
|
||||||
{
|
{
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Guid FromShowId { get; private set; }
|
|
||||||
public Guid ToShowId { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Канал/блок/подблок, по которым фоновый рендерер восстановит спецификацию заставки.</summary>
|
/// <summary>Хэш содержимого: блок + ревизия + подблок + подставленные строки + фон.</summary>
|
||||||
public Guid ChannelId { get; private set; }
|
public string Signature { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Блок и подблок — оформление рендера и чистка осиротевших ассетов.</summary>
|
||||||
public Guid TemplateId { get; private set; }
|
public Guid TemplateId { get; private set; }
|
||||||
public Guid VariantId { get; private set; }
|
public Guid VariantId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Хэш входных данных рендера (названия «из/в» + версия шаблона).</summary>
|
/// <summary>Готовые строки заставки (JSON). Домен их не интерпретирует — схема живёт в Application.</summary>
|
||||||
public string Signature { get; private set; } = string.Empty;
|
public string RenderedLinesJson { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Шоу, чей постер идёт фоном, или null (фон блока/градиент).</summary>
|
||||||
|
public Guid? PosterShowId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером).</summary>
|
/// <summary>Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером).</summary>
|
||||||
public Guid MediaAssetId { get; private set; }
|
public Guid MediaAssetId { get; private set; }
|
||||||
@@ -30,23 +35,21 @@ public class BumperAsset
|
|||||||
private BumperAsset() { }
|
private BumperAsset() { }
|
||||||
|
|
||||||
public static BumperAsset Create(
|
public static BumperAsset Create(
|
||||||
Guid channelId,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
Guid variantId,
|
Guid variantId,
|
||||||
Guid fromShowId,
|
|
||||||
Guid toShowId,
|
|
||||||
string signature,
|
string signature,
|
||||||
|
string renderedLinesJson,
|
||||||
|
Guid? posterShowId,
|
||||||
Guid mediaAssetId
|
Guid mediaAssetId
|
||||||
) =>
|
) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChannelId = channelId,
|
|
||||||
TemplateId = templateId,
|
TemplateId = templateId,
|
||||||
VariantId = variantId,
|
VariantId = variantId,
|
||||||
FromShowId = fromShowId,
|
|
||||||
ToShowId = toShowId,
|
|
||||||
Signature = signature,
|
Signature = signature,
|
||||||
|
RenderedLinesJson = renderedLinesJson,
|
||||||
|
PosterShowId = posterShowId,
|
||||||
MediaAssetId = mediaAssetId,
|
MediaAssetId = mediaAssetId,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Что за картинка под текстом заставки. Раньше постер подставлялся молча и только в режиме
|
||||||
|
/// «Сейчас/Далее»; при свободных строках такой связи взяться неоткуда, поэтому источник задаётся явно.
|
||||||
|
/// </summary>
|
||||||
|
public enum BumperBackground
|
||||||
|
{
|
||||||
|
/// <summary>Фон блока: загруженная картинка, а если её нет — анимированный градиент палитры.</summary>
|
||||||
|
Template = 0,
|
||||||
|
|
||||||
|
/// <summary>Постер следующего шоу (размытый и затемнённый).</summary>
|
||||||
|
NextPoster = 1,
|
||||||
|
|
||||||
|
/// <summary>Постер предыдущего шоу.</summary>
|
||||||
|
NowPoster = 2,
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
/// <summary>Роль строки в кадре заставки — от неё зависят размер и место.</summary>
|
||||||
|
public enum BumperLineStyle
|
||||||
|
{
|
||||||
|
/// <summary>Подпись: мелко, вразрядку — «СЕЙЧАС», «ДАЛЕЕ В 21:30».</summary>
|
||||||
|
Label = 0,
|
||||||
|
|
||||||
|
/// <summary>Название: крупно, ужимается под ширину кадра.</summary>
|
||||||
|
Title = 1,
|
||||||
|
|
||||||
|
/// <summary>Мелкая строка под названием — год, жанр, номер серии.</summary>
|
||||||
|
Caption = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Каким цветом палитры блока рисуется строка.</summary>
|
||||||
|
public enum BumperLineColor
|
||||||
|
{
|
||||||
|
Accent = 0,
|
||||||
|
Text = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка текста в заставке. Своих цветов и размеров у строки нет — только роль и цвет из палитры
|
||||||
|
/// блока: иначе каждый подблок пришлось бы оформлять заново, и общий блок перестал бы быть общим.
|
||||||
|
///
|
||||||
|
/// <see cref="Text"/> хранится с плейсхолдерами («ДАЛЕЕ В {next.time}»); подставляет их планировщик
|
||||||
|
/// в момент, когда пара соседей и время показа уже известны.
|
||||||
|
/// </summary>
|
||||||
|
public class BumperLine
|
||||||
|
{
|
||||||
|
public int Position { get; private set; }
|
||||||
|
public BumperLineStyle Style { get; private set; }
|
||||||
|
public BumperLineColor Color { get; private set; }
|
||||||
|
public string Text { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
private BumperLine() { }
|
||||||
|
|
||||||
|
public static BumperLine Create(
|
||||||
|
int position,
|
||||||
|
BumperLineStyle style,
|
||||||
|
BumperLineColor color,
|
||||||
|
string text
|
||||||
|
) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Position = position,
|
||||||
|
Style = style,
|
||||||
|
Color = color,
|
||||||
|
Text = text.Trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Как выбирать подблок заставки на переходе. Значения заданы явно: прежний вариант «по кругу»
|
|
||||||
/// (0) убран — курсора ротации в новом пайплайне нет, и он молча вырождался в случайный выбор.
|
|
||||||
/// </summary>
|
|
||||||
public enum BumperSelection
|
|
||||||
{
|
|
||||||
/// <summary>Случайный подблок на каждом переходе (равновероятно).</summary>
|
|
||||||
Random = 1,
|
|
||||||
|
|
||||||
/// <summary>Всегда первый (дефолтный) подблок.</summary>
|
|
||||||
AlwaysFirst = 2,
|
|
||||||
|
|
||||||
/// <summary>Случайный подблок с учётом веса (<see cref="BumperTextVariant.Weight"/>).</summary>
|
|
||||||
WeightedRandom = 3,
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,41 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
/// <summary>Оформление блока заставки: имя, шрифт и палитра (цвета — в нотации ffmpeg: 0xRRGGBB или имя).</summary>
|
||||||
|
public sealed record BumperStyle(
|
||||||
|
string Name,
|
||||||
|
BumperFont Font,
|
||||||
|
string BackgroundColor,
|
||||||
|
string BackgroundColor2,
|
||||||
|
string AccentColor,
|
||||||
|
string TextColor
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка) + набор
|
/// Блок ТВ-заставки: свой звук + своё оформление (шрифт, цвета, опциональная фон-картинка) + набор
|
||||||
/// подблоков (<see cref="Variants"/>) с разным текстом и правилом показа. Длительность заставки — по
|
/// подблоков (<see cref="Variants"/>) с разным текстом и правилом показа. Длительность заставки — по
|
||||||
/// длине звука (выравнивается на сегмент при рендере). Общий для канала — только шрифт.
|
/// длине звука (выравнивается на сегмент при рендере).
|
||||||
///
|
///
|
||||||
/// Первый блок (<see cref="Position"/> == 0) — дефолтный, не удаляется; если звук в нём не загружен,
|
/// Блок общий для всех каналов, как группа: файлы звука и фона и так лежат по идентификатору блока,
|
||||||
/// рендер синтезирует джингл по умолчанию.
|
/// канал в них никогда не участвовал. Поэтому же шрифт живёт здесь, а не на канале — иначе два
|
||||||
|
/// канала с разным шрифтом делили бы один отрендеренный ассет.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BumperTemplate
|
public class BumperTemplate
|
||||||
{
|
{
|
||||||
private readonly List<BumperTextVariant> _variants = [];
|
private readonly List<BumperTextVariant> _variants = [];
|
||||||
|
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Guid ChannelId { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Порядковый номер (0 — дефолтный блок). Используется ротацией и как признак дефолта.</summary>
|
|
||||||
public int Position { get; private set; }
|
|
||||||
|
|
||||||
public string Name { get; private set; } = string.Empty;
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public BumperFont Font { get; private set; }
|
||||||
|
|
||||||
// ── Оформление блока (цвета — в нотации ffmpeg: 0xRRGGBB или имя) ──
|
// ── Оформление блока (цвета — в нотации ffmpeg: 0xRRGGBB или имя) ──
|
||||||
public string BackgroundColor { get; private set; } = DefaultBackgroundColor;
|
public string BackgroundColor { get; private set; } = DefaultBackgroundColor;
|
||||||
public string BackgroundColor2 { get; private set; } = DefaultBackgroundColor2;
|
public string BackgroundColor2 { get; private set; } = DefaultBackgroundColor2;
|
||||||
public string AccentColor { get; private set; } = DefaultAccentColor;
|
public string AccentColor { get; private set; } = DefaultAccentColor;
|
||||||
public string TextColor { get; private set; } = DefaultTextColor;
|
public string TextColor { get; private set; } = DefaultTextColor;
|
||||||
|
|
||||||
/// <summary>Фон-картинка блока — ссылка на запись реестра изображений или null (тогда фон градиент/постер).</summary>
|
/// <summary>Фон-картинка блока — ссылка на запись реестра изображений или null (тогда фон градиент).</summary>
|
||||||
public Guid? BackgroundImageId { get; private set; }
|
public Guid? BackgroundImageId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл.</summary>
|
/// <summary>Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл.</summary>
|
||||||
@@ -35,7 +44,7 @@ public class BumperTemplate
|
|||||||
/// <summary>Длина загруженного звука в секундах (замер ffprobe) или null, если звука нет.</summary>
|
/// <summary>Длина загруженного звука в секундах (замер ffprobe) или null, если звука нет.</summary>
|
||||||
public double? AudioDurationSeconds { get; private set; }
|
public double? AudioDurationSeconds { get; private set; }
|
||||||
|
|
||||||
/// <summary>Версия файлов блока (звук/фон). Входит в кэш-ключ рендера — замена файла пересобирает заставки.</summary>
|
/// <summary>Версия блока (звук/фон/оформление). Входит в сигнатуру рендера — правка пересобирает заставки.</summary>
|
||||||
public int Revision { get; private set; }
|
public int Revision { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
@@ -45,23 +54,19 @@ public class BumperTemplate
|
|||||||
public const string DefaultAccentColor = "0x38bdf8";
|
public const string DefaultAccentColor = "0x38bdf8";
|
||||||
public const string DefaultTextColor = "white";
|
public const string DefaultTextColor = "white";
|
||||||
|
|
||||||
public bool IsDefault => Position == 0;
|
|
||||||
|
|
||||||
/// <summary>Подблоки (текст-варианты); порядок — по <see cref="BumperTextVariant.Position"/>.</summary>
|
/// <summary>Подблоки (текст-варианты); порядок — по <see cref="BumperTextVariant.Position"/>.</summary>
|
||||||
public IReadOnlyList<BumperTextVariant> Variants => _variants;
|
public IReadOnlyList<BumperTextVariant> Variants => _variants;
|
||||||
|
|
||||||
private const string DefaultVariantName = "Текст 1";
|
|
||||||
|
|
||||||
private BumperTemplate() { }
|
private BumperTemplate() { }
|
||||||
|
|
||||||
internal static BumperTemplate Create(Guid channelId, int position, string name)
|
/// <summary>Создаёт блок с одним пустым подблоком — текст в него кладёт вызывающий (пресетом).</summary>
|
||||||
|
public static BumperTemplate Create(string name, string variantName)
|
||||||
{
|
{
|
||||||
var template = new BumperTemplate
|
var template = new BumperTemplate
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChannelId = channelId,
|
Name = name.Trim(),
|
||||||
Position = position,
|
Font = BumperFont.Sans,
|
||||||
Name = name,
|
|
||||||
BackgroundColor = DefaultBackgroundColor,
|
BackgroundColor = DefaultBackgroundColor,
|
||||||
BackgroundColor2 = DefaultBackgroundColor2,
|
BackgroundColor2 = DefaultBackgroundColor2,
|
||||||
AccentColor = DefaultAccentColor,
|
AccentColor = DefaultAccentColor,
|
||||||
@@ -72,9 +77,8 @@ public class BumperTemplate
|
|||||||
Revision = 0,
|
Revision = 0,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
// Дефолтный подблок «Сейчас/Далее», показывается на смене шоу.
|
|
||||||
template._variants.Add(
|
template._variants.Add(
|
||||||
BumperTextVariant.Create(template.Id, 0, DefaultVariantName, BumperTrigger.OnShowChange)
|
BumperTextVariant.Create(template.Id, 0, variantName, BumperTrigger.OnShowChange)
|
||||||
);
|
);
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
@@ -102,20 +106,19 @@ public class BumperTemplate
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</summary>
|
/// <summary>
|
||||||
public void UpdateStyle(
|
/// Обновить имя, шрифт и цвета блока. Меняет ревизию: оформление входит в сигнатуру рендера,
|
||||||
string name,
|
/// иначе смена шрифта оставила бы в эфире заставки, набранные прежним.
|
||||||
string backgroundColor,
|
/// </summary>
|
||||||
string backgroundColor2,
|
public void UpdateStyle(BumperStyle style)
|
||||||
string accentColor,
|
|
||||||
string textColor
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = style.Name.Trim();
|
||||||
BackgroundColor = backgroundColor;
|
Font = style.Font;
|
||||||
BackgroundColor2 = backgroundColor2;
|
BackgroundColor = style.BackgroundColor;
|
||||||
AccentColor = accentColor;
|
BackgroundColor2 = style.BackgroundColor2;
|
||||||
TextColor = textColor;
|
AccentColor = style.AccentColor;
|
||||||
|
TextColor = style.TextColor;
|
||||||
|
Revision++;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Отметить загруженный звук (extension — с точкой, нижний регистр) и его длину. Меняет ревизию.</summary>
|
/// <summary>Отметить загруженный звук (extension — с точкой, нижний регистр) и его длину. Меняет ревизию.</summary>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Текстовое наполнение подблока заставки. Наборы полей взаимоисключающие: при
|
|
||||||
/// <see cref="BumperTextKind.NowNext"/> работают подписи, при <see cref="BumperTextKind.Free"/> —
|
|
||||||
/// произвольные строки; неиспользуемые просто хранятся, чтобы переключение режима не теряло ввод.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BumperTextContent(
|
|
||||||
BumperTextKind Kind,
|
|
||||||
string NowLabel,
|
|
||||||
string NextLabel,
|
|
||||||
string Line1,
|
|
||||||
string Line2
|
|
||||||
);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
|
||||||
|
|
||||||
/// <summary>Как формируется текст подблока заставки.</summary>
|
|
||||||
public enum BumperTextKind
|
|
||||||
{
|
|
||||||
/// <summary>«Сейчас/Далее»: две подписи + названия текущего и следующего шоу.</summary>
|
|
||||||
NowNext,
|
|
||||||
|
|
||||||
/// <summary>Произвольные строки (без названий шоу) — например название канала и совет.</summary>
|
|
||||||
Free,
|
|
||||||
}
|
|
||||||
@@ -2,37 +2,32 @@ namespace TeleWave.Domain.Broadcast;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Подблок заставки (текст-вариант) внутри <see cref="BumperTemplate"/>. Наследует от блока звук,
|
/// Подблок заставки (текст-вариант) внутри <see cref="BumperTemplate"/>. Наследует от блока звук,
|
||||||
/// стиль и фон, но задаёт собственный текст и правило показа (<see cref="Trigger"/>). Позволяет иметь
|
/// стиль и шрифт, но задаёт собственный набор строк, фон и правило показа (<see cref="Trigger"/>).
|
||||||
/// несколько текстов на одной музыке/оформлении, не дублируя блок.
|
/// Позволяет иметь несколько текстов на одной музыке/оформлении, не дублируя блок.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BumperTextVariant
|
public class BumperTextVariant
|
||||||
{
|
{
|
||||||
|
private readonly List<BumperLine> _lines = [];
|
||||||
|
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Guid BumperTemplateId { get; private set; }
|
public Guid BumperTemplateId { get; private set; }
|
||||||
public int Position { get; private set; }
|
public int Position { get; private set; }
|
||||||
public string Name { get; private set; } = string.Empty;
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
|
||||||
public BumperTextKind Kind { get; private set; }
|
|
||||||
|
|
||||||
// ── Режим NowNext: подписи (названия шоу подставляет генератор) ──
|
|
||||||
public string NowLabel { get; private set; } = DefaultNowLabel;
|
|
||||||
public string NextLabel { get; private set; } = DefaultNextLabel;
|
|
||||||
|
|
||||||
// ── Режим Free: произвольные строки (например название канала и совет) ──
|
|
||||||
public string Line1 { get; private set; } = string.Empty;
|
|
||||||
public string Line2 { get; private set; } = string.Empty;
|
|
||||||
|
|
||||||
public BumperTrigger Trigger { get; private set; }
|
public BumperTrigger Trigger { get; private set; }
|
||||||
|
|
||||||
/// <summary>Вес при стратегии <see cref="BumperSelection.WeightedRandom"/> (0 — не выбирается). Иначе игнорируется.</summary>
|
/// <summary>Источник картинки под текстом.</summary>
|
||||||
|
public BumperBackground Background { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Вес при выборе подблока на переходе (0 — не выбирается никогда).</summary>
|
||||||
public int Weight { get; private set; } = DefaultWeight;
|
public int Weight { get; private set; } = DefaultWeight;
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
public const int DefaultWeight = 1;
|
public const int DefaultWeight = 1;
|
||||||
|
|
||||||
public const string DefaultNowLabel = "СЕЙЧАС";
|
/// <summary>Строки в порядке показа сверху вниз.</summary>
|
||||||
public const string DefaultNextLabel = "ДАЛЕЕ";
|
public IReadOnlyList<BumperLine> Lines => _lines;
|
||||||
|
|
||||||
private BumperTextVariant() { }
|
private BumperTextVariant() { }
|
||||||
|
|
||||||
@@ -48,28 +43,32 @@ public class BumperTextVariant
|
|||||||
BumperTemplateId = bumperTemplateId,
|
BumperTemplateId = bumperTemplateId,
|
||||||
Position = position,
|
Position = position,
|
||||||
Name = name,
|
Name = name,
|
||||||
Kind = BumperTextKind.NowNext,
|
|
||||||
NowLabel = DefaultNowLabel,
|
|
||||||
NextLabel = DefaultNextLabel,
|
|
||||||
Line1 = string.Empty,
|
|
||||||
Line2 = string.Empty,
|
|
||||||
Trigger = trigger,
|
Trigger = trigger,
|
||||||
|
Background = BumperBackground.NextPoster,
|
||||||
Weight = DefaultWeight,
|
Weight = DefaultWeight,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
public void Update(string name, BumperTextContent text, BumperTrigger trigger, int weight)
|
public void Update(string name, BumperTrigger trigger, BumperBackground background, int weight)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
Kind = text.Kind;
|
|
||||||
NowLabel = text.NowLabel;
|
|
||||||
NextLabel = text.NextLabel;
|
|
||||||
Line1 = text.Line1;
|
|
||||||
Line2 = text.Line2;
|
|
||||||
Trigger = trigger;
|
Trigger = trigger;
|
||||||
|
Background = background;
|
||||||
Weight = Math.Max(0, weight);
|
Weight = Math.Max(0, weight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Заменяет набор строк целиком. Построчных команд намеренно нет: редактор всегда знает весь
|
||||||
|
/// список, а порядок задаётся перетаскиванием — три команды вместо одной ничего бы не дали.
|
||||||
|
/// </summary>
|
||||||
|
public void SetLines(IEnumerable<BumperLine> lines)
|
||||||
|
{
|
||||||
|
_lines.Clear();
|
||||||
|
var position = 0;
|
||||||
|
foreach (var line in lines)
|
||||||
|
_lines.Add(BumperLine.Create(position++, line.Style, line.Color, line.Text));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Подходит ли подблок для перехода: <paramref name="isShowChange"/> — сменилось ли шоу.</summary>
|
/// <summary>Подходит ли подблок для перехода: <paramref name="isShowChange"/> — сменилось ли шоу.</summary>
|
||||||
public bool Matches(bool isShowChange) =>
|
public bool Matches(bool isShowChange) =>
|
||||||
Trigger switch
|
Trigger switch
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ namespace TeleWave.Domain.Broadcast;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
|
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
|
||||||
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
|
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
|
||||||
/// филлер и общие настройки заставок.
|
/// филлер и настройки зрительской части.
|
||||||
|
///
|
||||||
|
/// Заставок на канале нет: блоки заставок общие (см. <see cref="BumperTemplate"/>), а условия их
|
||||||
|
/// показа — во врезках стыка.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Channel
|
public class Channel
|
||||||
{
|
{
|
||||||
private readonly List<BumperTemplate> _bumperTemplates = [];
|
|
||||||
|
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public string Name { get; private set; } = string.Empty;
|
public string Name { get; private set; } = string.Empty;
|
||||||
public string Slug { get; private set; } = string.Empty;
|
public string Slug { get; private set; } = string.Empty;
|
||||||
@@ -42,19 +43,6 @@ public class Channel
|
|||||||
public const int DefaultUtcOffsetMinutes = 180;
|
public const int DefaultUtcOffsetMinutes = 180;
|
||||||
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
|
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
|
||||||
|
|
||||||
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
|
|
||||||
// живут в элементах стыка; на канале осталось только общее для всех заставок. ──
|
|
||||||
|
|
||||||
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
|
|
||||||
public bool BumpersEnabled { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
|
|
||||||
public BumperSelection BumperSelection { get; private set; }
|
|
||||||
|
|
||||||
public BumperFont BumperFont { get; private set; }
|
|
||||||
|
|
||||||
private const string DefaultTemplateName = "Заставка 1";
|
|
||||||
|
|
||||||
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
||||||
public Guid? FillerAssetId { get; private set; }
|
public Guid? FillerAssetId { get; private set; }
|
||||||
|
|
||||||
@@ -77,53 +65,29 @@ public class Channel
|
|||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
|
|
||||||
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
|
|
||||||
|
|
||||||
private Channel() { }
|
private Channel() { }
|
||||||
|
|
||||||
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
|
public static Channel Create(string name, string slug, DateTimeOffset epochUtc) =>
|
||||||
{
|
new()
|
||||||
var channel = new Channel
|
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = name,
|
Name = name,
|
||||||
Slug = slug,
|
Slug = slug,
|
||||||
IsEnabled = true,
|
IsEnabled = true,
|
||||||
EpochUtc = epochUtc,
|
EpochUtc = epochUtc,
|
||||||
BumpersEnabled = false,
|
|
||||||
BumperSelection = BumperSelection.WeightedRandom,
|
|
||||||
BumperFont = BumperFont.Sans,
|
|
||||||
LogoOpacity = 0.8,
|
LogoOpacity = 0.8,
|
||||||
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
|
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
|
||||||
DayStartTime = DefaultDayStartTime,
|
DayStartTime = DefaultDayStartTime,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
|
|
||||||
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateSettings(
|
public void UpdateSettings(string name, bool isEnabled, Guid? fillerAssetId)
|
||||||
string name,
|
|
||||||
bool isEnabled,
|
|
||||||
bool bumpersEnabled,
|
|
||||||
Guid? fillerAssetId
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
IsEnabled = isEnabled;
|
IsEnabled = isEnabled;
|
||||||
BumpersEnabled = bumpersEnabled;
|
|
||||||
FillerAssetId = fillerAssetId;
|
FillerAssetId = fillerAssetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
|
|
||||||
public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
|
|
||||||
{
|
|
||||||
BumperFont = font;
|
|
||||||
BumperSelection = selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
|
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
|
||||||
public void UpdateViewerSettings(
|
public void UpdateViewerSettings(
|
||||||
Guid? logoImageId,
|
Guid? logoImageId,
|
||||||
@@ -140,29 +104,6 @@ public class Channel
|
|||||||
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
|
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
|
|
||||||
public BumperTemplate AddBumperTemplate(string name)
|
|
||||||
{
|
|
||||||
var nextPosition =
|
|
||||||
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
|
|
||||||
var template = BumperTemplate.Create(Id, nextPosition, name);
|
|
||||||
_bumperTemplates.Add(template);
|
|
||||||
return template;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
|
|
||||||
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
|
||||||
|
|
||||||
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
|
|
||||||
public bool RemoveBumperTemplate(Guid templateId)
|
|
||||||
{
|
|
||||||
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
|
||||||
if (template is null || template.IsDefault)
|
|
||||||
return false;
|
|
||||||
_bumperTemplates.Remove(template);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Привязать активный шаблон сетки.</summary>
|
/// <summary>Привязать активный шаблон сетки.</summary>
|
||||||
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
|
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ public enum JunctionAmountMode
|
|||||||
Duration = 1,
|
Duration = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Полный набор настроек врезки. Собран в запись, потому что по отдельности эти девять значений
|
||||||
|
/// ехали бы через команду, хендлер и домен плоским списком, в котором ничего не читается.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record JunctionElementSettings(
|
||||||
|
JunctionElementKind Kind,
|
||||||
|
Guid? GroupId,
|
||||||
|
Guid? BumperTemplateId,
|
||||||
|
Guid? BumperVariantId,
|
||||||
|
JunctionAmountMode AmountMode,
|
||||||
|
int AmountValue,
|
||||||
|
bool IsRequired,
|
||||||
|
string? ChoiceKey,
|
||||||
|
int ChoiceWeight,
|
||||||
|
string? ConditionsJson
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Врезка в шаблоне стыка. Условия показа хранятся структурно (<see cref="ConditionsJson"/>),
|
/// Врезка в шаблоне стыка. Условия показа хранятся структурно (<see cref="ConditionsJson"/>),
|
||||||
/// а не выражением: парсер, его валидация и отдельный UI обошлись бы дорого, а покрывают ровно
|
/// а не выражением: парсер, его валидация и отдельный UI обошлись бы дорого, а покрывают ровно
|
||||||
@@ -38,7 +55,10 @@ public class JunctionElement
|
|||||||
{
|
{
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Guid JunctionTemplateId { get; private set; }
|
public Guid JunctionTemplateId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Порядок показа в эфире — и только он: обязательность на порядок не влияет.</summary>
|
||||||
public int Position { get; private set; }
|
public int Position { get; private set; }
|
||||||
|
|
||||||
public JunctionElementKind Kind { get; private set; }
|
public JunctionElementKind Kind { get; private set; }
|
||||||
|
|
||||||
/// <summary>Откуда брать единицы — для <see cref="JunctionElementKind.Ad"/>, <see cref="JunctionElementKind.Promo"/>, <see cref="JunctionElementKind.Filler"/>.</summary>
|
/// <summary>Откуда брать единицы — для <see cref="JunctionElementKind.Ad"/>, <see cref="JunctionElementKind.Promo"/>, <see cref="JunctionElementKind.Filler"/>.</summary>
|
||||||
@@ -47,6 +67,9 @@ public class JunctionElement
|
|||||||
/// <summary>Какой блок заставки рендерить — для <see cref="JunctionElementKind.Bumper"/>.</summary>
|
/// <summary>Какой блок заставки рендерить — для <see cref="JunctionElementKind.Bumper"/>.</summary>
|
||||||
public Guid? BumperTemplateId { get; private set; }
|
public Guid? BumperTemplateId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Конкретный подблок заставки; null — выбрать по триггеру перехода и весам.</summary>
|
||||||
|
public Guid? BumperVariantId { get; private set; }
|
||||||
|
|
||||||
public JunctionAmountMode AmountMode { get; private set; }
|
public JunctionAmountMode AmountMode { get; private set; }
|
||||||
|
|
||||||
/// <summary>Единиц либо минут.</summary>
|
/// <summary>Единиц либо минут.</summary>
|
||||||
@@ -55,9 +78,20 @@ public class JunctionElement
|
|||||||
/// <summary>Обязательную врезку нельзя выбросить при нехватке времени.</summary>
|
/// <summary>Обязательную врезку нельзя выбросить при нехватке времени.</summary>
|
||||||
public bool IsRequired { get; private set; }
|
public bool IsRequired { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Метка развилки: из врезок с одной меткой в эфир идёт одна, выбранная по весам. Врезки одной
|
||||||
|
/// развилки обязаны занимать непрерывный отрезок позиций — иначе неясно, куда встаёт выбранная.
|
||||||
|
/// </summary>
|
||||||
|
public string? ChoiceKey { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Вес внутри развилки (0 — не выбирается). Вне развилки не используется.</summary>
|
||||||
|
public int ChoiceWeight { get; private set; }
|
||||||
|
|
||||||
/// <summary>Условия показа (JSON). Домен их не интерпретирует — схема живёт в Application.</summary>
|
/// <summary>Условия показа (JSON). Домен их не интерпретирует — схема живёт в Application.</summary>
|
||||||
public string? ConditionsJson { get; private set; }
|
public string? ConditionsJson { get; private set; }
|
||||||
|
|
||||||
|
public const int DefaultChoiceWeight = 1;
|
||||||
|
|
||||||
private JunctionElement() { }
|
private JunctionElement() { }
|
||||||
|
|
||||||
internal static JunctionElement Create(
|
internal static JunctionElement Create(
|
||||||
@@ -74,28 +108,36 @@ public class JunctionElement
|
|||||||
AmountMode = JunctionAmountMode.Count,
|
AmountMode = JunctionAmountMode.Count,
|
||||||
AmountValue = 1,
|
AmountValue = 1,
|
||||||
IsRequired = false,
|
IsRequired = false,
|
||||||
|
ChoiceWeight = DefaultChoiceWeight,
|
||||||
};
|
};
|
||||||
|
|
||||||
public void Update(
|
public void Update(JunctionElementSettings settings)
|
||||||
JunctionElementKind kind,
|
|
||||||
Guid? groupId,
|
|
||||||
Guid? bumperTemplateId,
|
|
||||||
JunctionAmountMode amountMode,
|
|
||||||
int amountValue,
|
|
||||||
bool isRequired,
|
|
||||||
string? conditionsJson
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Kind = kind;
|
Kind = settings.Kind;
|
||||||
AmountMode = amountMode;
|
AmountMode = settings.AmountMode;
|
||||||
AmountValue = Math.Max(1, amountValue);
|
AmountValue = Math.Max(1, settings.AmountValue);
|
||||||
IsRequired = isRequired;
|
IsRequired = settings.IsRequired;
|
||||||
ConditionsJson = string.IsNullOrWhiteSpace(conditionsJson) ? null : conditionsJson;
|
ChoiceKey = string.IsNullOrWhiteSpace(settings.ChoiceKey)
|
||||||
|
? null
|
||||||
|
: settings.ChoiceKey.Trim();
|
||||||
|
ChoiceWeight = Math.Max(0, settings.ChoiceWeight);
|
||||||
|
ConditionsJson = string.IsNullOrWhiteSpace(settings.ConditionsJson)
|
||||||
|
? null
|
||||||
|
: settings.ConditionsJson;
|
||||||
|
|
||||||
// Источник зависит от типа врезки: у заставки нет группы, у остальных нет блока заставки.
|
// Источник зависит от типа врезки: у заставки нет группы, у остальных нет блока заставки.
|
||||||
// Оставленная от прежнего типа ссылка потом читалась бы генератором как настройка.
|
// Оставленная от прежнего типа ссылка потом читалась бы генератором как настройка.
|
||||||
GroupId = kind == JunctionElementKind.Bumper ? null : groupId;
|
var isBumper = settings.Kind == JunctionElementKind.Bumper;
|
||||||
BumperTemplateId = kind == JunctionElementKind.Bumper ? bumperTemplateId : null;
|
GroupId = isBumper ? null : settings.GroupId;
|
||||||
|
BumperTemplateId = isBumper ? settings.BumperTemplateId : null;
|
||||||
|
BumperVariantId = isBumper ? settings.BumperVariantId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Перевесить врезку в другую развилку (или вынести из неё) — это делает перетаскивание.</summary>
|
||||||
|
public void SetChoice(string? choiceKey, int weight)
|
||||||
|
{
|
||||||
|
ChoiceKey = string.IsNullOrWhiteSpace(choiceKey) ? null : choiceKey.Trim();
|
||||||
|
ChoiceWeight = Math.Max(0, weight);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void SetPosition(int position) => Position = position;
|
internal void SetPosition(int position) => Position = position;
|
||||||
|
|||||||
@@ -4,14 +4,21 @@ namespace TeleWave.Domain.Programming;
|
|||||||
/// Шаблон стыка: что играет между программами. Реклама и заставки перестают быть свойствами канала
|
/// Шаблон стыка: что играет между программами. Реклама и заставки перестают быть свойствами канала
|
||||||
/// и становятся врезками стыка — за счёт этого в прайм можно поставить три ролика и заставку,
|
/// и становятся врезками стыка — за счёт этого в прайм можно поставить три ролика и заставку,
|
||||||
/// а ночью один длинный ролик, чего одной настройкой на канал не сделать.
|
/// а ночью один длинный ролик, чего одной настройкой на канал не сделать.
|
||||||
|
///
|
||||||
|
/// Стык общий для всех каналов, как группа: «рекламный блок на две минуты с заставкой в конце» —
|
||||||
|
/// такой же переиспользуемый ресурс, как «Боевики 90-х». Канальным остаётся только выбор, какой
|
||||||
|
/// стык поставить в слот.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class JunctionTemplate
|
public class JunctionTemplate
|
||||||
{
|
{
|
||||||
private readonly List<JunctionElement> _elements = [];
|
private readonly List<JunctionElement> _elements = [];
|
||||||
|
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Guid ChannelId { get; private set; }
|
|
||||||
public string Name { get; private set; } = string.Empty;
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Потолок длины стыка целиком в секундах; null — ограничен только якорем.</summary>
|
||||||
|
public int? MaxTotalSeconds { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
/// <summary>Врезки в порядке показа (backing-field для EF).</summary>
|
/// <summary>Врезки в порядке показа (backing-field для EF).</summary>
|
||||||
@@ -19,16 +26,19 @@ public class JunctionTemplate
|
|||||||
|
|
||||||
private JunctionTemplate() { }
|
private JunctionTemplate() { }
|
||||||
|
|
||||||
public static JunctionTemplate Create(Guid channelId, string name) =>
|
public static JunctionTemplate Create(string name) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChannelId = channelId,
|
|
||||||
Name = name.Trim(),
|
Name = name.Trim(),
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
public void Rename(string name) => Name = name.Trim();
|
public void Update(string name, int? maxTotalSeconds)
|
||||||
|
{
|
||||||
|
Name = name.Trim();
|
||||||
|
MaxTotalSeconds = maxTotalSeconds is > 0 ? maxTotalSeconds : null;
|
||||||
|
}
|
||||||
|
|
||||||
public JunctionElement AddElement(JunctionElementKind kind)
|
public JunctionElement AddElement(JunctionElementKind kind)
|
||||||
{
|
{
|
||||||
@@ -47,6 +57,7 @@ public class JunctionTemplate
|
|||||||
if (element is null)
|
if (element is null)
|
||||||
return false;
|
return false;
|
||||||
_elements.Remove(element);
|
_elements.Remove(element);
|
||||||
|
Normalize(_elements.OrderBy(e => e.Position).Select(e => e.Id));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +74,38 @@ public class JunctionTemplate
|
|||||||
.OrderBy(e => e.Position)
|
.OrderBy(e => e.Position)
|
||||||
.Select(e => e.Id);
|
.Select(e => e.Id);
|
||||||
|
|
||||||
|
Normalize(requested.Concat(rest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Расставляет позиции, стягивая врезки одной развилки в непрерывный отрезок: развилка — это одно
|
||||||
|
/// место в цепочке, и её участники, разбросанные по стыку, не имели бы смысла. Каждая приезжает
|
||||||
|
/// к первому упоминанию своей метки, поэтому перетащить развилку целиком можно за любого участника.
|
||||||
|
/// </summary>
|
||||||
|
private void Normalize(IEnumerable<Guid> order)
|
||||||
|
{
|
||||||
|
var byId = _elements.ToDictionary(e => e.Id);
|
||||||
|
var ordered = order.Select(id => byId[id]).ToList();
|
||||||
|
|
||||||
|
var clustered = new List<JunctionElement>();
|
||||||
|
var placedChoices = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var element in ordered)
|
||||||
|
{
|
||||||
|
if (element.ChoiceKey is not { } key)
|
||||||
|
{
|
||||||
|
clustered.Add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!placedChoices.Add(key))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
clustered.AddRange(ordered.Where(e => e.ChoiceKey == key));
|
||||||
|
}
|
||||||
|
|
||||||
var position = 0;
|
var position = 0;
|
||||||
foreach (var id in requested.Concat(rest))
|
foreach (var element in clustered)
|
||||||
_elements.First(e => e.Id == id).SetPosition(position++);
|
element.SetPosition(position++);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,76 @@
|
|||||||
|
using TeleWave.Domain.Broadcast.Scheduling;
|
||||||
|
|
||||||
namespace TeleWave.Domain.Programming.Planning;
|
namespace TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
/// <summary>Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз.</summary>
|
/// <summary>
|
||||||
|
/// Состояние стыков в рамках прогона: когда какая врезка ставилась последний раз и на какой единице
|
||||||
|
/// группы она остановилась.
|
||||||
|
///
|
||||||
|
/// Ключ — конкретная врезка, а не её вид: две рекламные врезки в разных стыках это разные
|
||||||
|
/// ограничения, общий счётчик на вид склеил бы их в одно.
|
||||||
|
/// </summary>
|
||||||
public sealed class JunctionHistory
|
public sealed class JunctionHistory
|
||||||
{
|
{
|
||||||
private readonly Dictionary<JunctionElementKind, DateTimeOffset> _lastPlaced = [];
|
private readonly Dictionary<Guid, DateTimeOffset> _lastPlaced = [];
|
||||||
|
private readonly Dictionary<Guid, int> _nextUnit = [];
|
||||||
|
|
||||||
public bool Allows(PlanningJunctionElement element, DateTimeOffset moment)
|
public bool Allows(PlanningJunctionElement element, DateTimeOffset moment)
|
||||||
{
|
{
|
||||||
if (element.MinMinutesBetween <= 0)
|
if (element.MinMinutesBetween <= 0)
|
||||||
return true;
|
return true;
|
||||||
if (!_lastPlaced.TryGetValue(element.Kind, out var last))
|
if (!_lastPlaced.TryGetValue(element.ElementId, out var last))
|
||||||
return true;
|
return true;
|
||||||
return moment - last >= TimeSpan.FromMinutes(element.MinMinutesBetween);
|
return moment - last >= TimeSpan.FromMinutes(element.MinMinutesBetween);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Record(PlanningJunctionElement element, DateTimeOffset moment) =>
|
public void Record(PlanningJunctionElement element, DateTimeOffset moment) =>
|
||||||
_lastPlaced[element.Kind] = moment;
|
_lastPlaced[element.ElementId] = moment;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// С какой единицы группы врезка продолжает набор. Без этого каждый рекламный блок начинался бы
|
||||||
|
/// с одного и того же ролика — в эфире это слышно с первой же секунды.
|
||||||
|
/// </summary>
|
||||||
|
public int NextUnit(PlanningJunctionElement element) =>
|
||||||
|
_nextUnit.TryGetValue(element.ElementId, out var index) ? index : 0;
|
||||||
|
|
||||||
|
public void AdvanceUnits(PlanningJunctionElement element, int consumed)
|
||||||
|
{
|
||||||
|
if (element.Units.Count == 0 || consumed <= 0)
|
||||||
|
return;
|
||||||
|
_nextUnit[element.ElementId] = (NextUnit(element) + consumed) % element.Units.Count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти четыре значения ехали
|
/// Где ставится стык и между чем. Собрано в один параметр: по отдельности эти значения ехали
|
||||||
/// сквозь всю раскладку и вместе с курсором, пределом и накопителями раздували сигнатуры.
|
/// сквозь всю раскладку и вместе с курсором, пределом и накопителями раздували сигнатуры.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="FromShowId">Шоу перед стыком, <paramref name="ToShowId"/> — после; заставке нужна
|
/// <param name="FromShowId">Шоу перед стыком, <paramref name="ToShowId"/> — после; заставке нужна
|
||||||
/// именно пара соседей, её ассет рендерится под неё после сборки ленты.</param>
|
/// именно пара соседей, её ассет рендерится под неё после сборки ленты.</param>
|
||||||
|
/// <param name="ChannelOffset">Смещение времени канала от UTC — по нему считается окно суток врезки.</param>
|
||||||
public sealed record JunctionPlacement(
|
public sealed record JunctionPlacement(
|
||||||
Guid SlotId,
|
Guid SlotId,
|
||||||
Guid? FromShowId,
|
Guid? FromShowId,
|
||||||
Guid? ToShowId,
|
Guid? ToShowId,
|
||||||
bool ElementChanged
|
bool ElementChanged,
|
||||||
|
TimeSpan ChannelOffset = default
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Готовая к постановке врезка: что именно играет и сколько это займёт.</summary>
|
||||||
|
public sealed record JunctionInsert(
|
||||||
|
PlanningJunctionElement Element,
|
||||||
|
IReadOnlyList<PlanningUnit> Units,
|
||||||
|
TimeSpan Duration
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. Ставит только то, что влезает
|
/// Раскладка врезок стыка: реклама, промо, заставка, заполнитель.
|
||||||
/// целиком до предела (якорь или горизонт) — обрезать врезку нельзя, а перехлёст сдвинул бы якорь.
|
|
||||||
///
|
///
|
||||||
/// Обязательные врезки (<see cref="PlanningJunctionElement.IsRequired"/>) идут первыми: при нехватке
|
/// Порядок показа — это <see cref="JunctionElement.Position"/>, и только он. Обязательность
|
||||||
/// времени выбрасываются необязательные, а не то, ради чего стык и заведён.
|
/// (<see cref="PlanningJunctionElement.IsRequired"/>) участвует единственным способом: когда до
|
||||||
|
/// предела (якорь или горизонт) влезает не всё, с конца отбрасываются необязательные врезки. Иначе
|
||||||
|
/// галочка «обязательно» молча поднимала бы рекламу перед заставкой, и цепочка в редакторе
|
||||||
|
/// перестала бы соответствовать эфиру.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class JunctionFiller
|
public static class JunctionFiller
|
||||||
{
|
{
|
||||||
@@ -47,6 +81,7 @@ public static class JunctionFiller
|
|||||||
DateTimeOffset limit,
|
DateTimeOffset limit,
|
||||||
JunctionPlacement placement,
|
JunctionPlacement placement,
|
||||||
JunctionHistory history,
|
JunctionHistory history,
|
||||||
|
IRandomSource random,
|
||||||
List<PlannedItem> items,
|
List<PlannedItem> items,
|
||||||
PlanTrace? trace
|
PlanTrace? trace
|
||||||
)
|
)
|
||||||
@@ -54,37 +89,191 @@ public static class JunctionFiller
|
|||||||
if (junction is null || junction.Elements.Count == 0)
|
if (junction is null || junction.Elements.Count == 0)
|
||||||
return cursor;
|
return cursor;
|
||||||
|
|
||||||
var ordered = junction
|
var eligible = junction
|
||||||
.Elements.OrderByDescending(e => e.IsRequired)
|
.Elements.Where(e => Passes(e, cursor, placement, history, random))
|
||||||
.ThenBy(e => junction.Elements.ToList().IndexOf(e))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
var chosen = ResolveChoices(eligible, random);
|
||||||
|
|
||||||
foreach (var element in ordered)
|
var available = limit - cursor;
|
||||||
|
if (junction.MaxTotal is { } cap && cap < available)
|
||||||
|
available = cap;
|
||||||
|
if (available <= TimeSpan.Zero)
|
||||||
|
return cursor;
|
||||||
|
|
||||||
|
var inserts = Trim(chosen.Select(e => Build(e, history)).ToList(), available);
|
||||||
|
|
||||||
|
foreach (var insert in inserts)
|
||||||
{
|
{
|
||||||
if (element.OnlyOnElementChange && !placement.ElementChanged)
|
|
||||||
continue;
|
|
||||||
if (!history.Allows(element, cursor))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var placedAt = cursor;
|
var placedAt = cursor;
|
||||||
|
var before = items.Count;
|
||||||
cursor =
|
cursor =
|
||||||
element.Kind == JunctionElementKind.Bumper
|
insert.Element.Kind == JunctionElementKind.Bumper
|
||||||
? PlaceBumper(element, cursor, limit, placement, items, trace)
|
? PlaceBumper(insert, cursor, limit, placement, items, trace)
|
||||||
: PlaceUnits(element, cursor, limit, placement.SlotId, items, trace);
|
: PlaceUnits(insert, cursor, limit, placement.SlotId, items, trace);
|
||||||
|
|
||||||
if (cursor > placedAt)
|
if (cursor <= placedAt)
|
||||||
history.Record(element, placedAt);
|
continue;
|
||||||
|
|
||||||
|
history.Record(insert.Element, placedAt);
|
||||||
|
// Двигаем ротацию ровно на поставленное: часть единиц могла не влезть до предела.
|
||||||
|
history.AdvanceUnits(insert.Element, items.Count - before);
|
||||||
}
|
}
|
||||||
|
|
||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, жребий.</summary>
|
||||||
|
private static bool Passes(
|
||||||
|
PlanningJunctionElement element,
|
||||||
|
DateTimeOffset cursor,
|
||||||
|
JunctionPlacement placement,
|
||||||
|
JunctionHistory history,
|
||||||
|
IRandomSource random
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (element.OnlyOnElementChange && !placement.ElementChanged)
|
||||||
|
return false;
|
||||||
|
if (!history.Allows(element, cursor))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (element.TimeWindow is { } window)
|
||||||
|
{
|
||||||
|
var local = TimeOnly.FromTimeSpan(cursor.ToOffset(placement.ChannelOffset).TimeOfDay);
|
||||||
|
if (!window.Contains(local))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации,
|
||||||
|
// поэтому пересборка хвоста не перетасовывает врезки на каждое применение.
|
||||||
|
return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Схлопывает развилки: из врезок с одной меткой остаётся одна, выбранная по весам. Позиция
|
||||||
|
/// развилки в цепочке — позиция её первого участника.
|
||||||
|
/// </summary>
|
||||||
|
private static List<PlanningJunctionElement> ResolveChoices(
|
||||||
|
IReadOnlyList<PlanningJunctionElement> elements,
|
||||||
|
IRandomSource random
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = new List<PlanningJunctionElement>();
|
||||||
|
var resolved = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var element in elements)
|
||||||
|
{
|
||||||
|
if (element.ChoiceKey is not { } key)
|
||||||
|
{
|
||||||
|
result.Add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.Add(key))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var candidates = elements.Where(e => e.ChoiceKey == key).ToList();
|
||||||
|
result.Add(WeightedPick(candidates, random));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlanningJunctionElement WeightedPick(
|
||||||
|
IReadOnlyList<PlanningJunctionElement> candidates,
|
||||||
|
IRandomSource random
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var total = candidates.Sum(c => Math.Max(0, c.ChoiceWeight));
|
||||||
|
if (total <= 0)
|
||||||
|
return candidates[random.Next(candidates.Count)];
|
||||||
|
|
||||||
|
var roll = random.Next(total);
|
||||||
|
var accumulated = 0;
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
accumulated += Math.Max(0, candidate.ChoiceWeight);
|
||||||
|
if (roll < accumulated)
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[^1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Подбирает единицы врезки по её бюджету, не двигая состояние: ставить или нет — решит <see cref="Trim"/>.</summary>
|
||||||
|
private static JunctionInsert Build(PlanningJunctionElement element, JunctionHistory history)
|
||||||
|
{
|
||||||
|
if (element.Kind == JunctionElementKind.Bumper)
|
||||||
|
return new JunctionInsert(element, [], element.BumperDuration);
|
||||||
|
|
||||||
|
var units = new List<PlanningUnit>();
|
||||||
|
var accumulated = TimeSpan.Zero;
|
||||||
|
var start = history.NextUnit(element);
|
||||||
|
|
||||||
|
for (var taken = 0; taken < element.Units.Count; taken++)
|
||||||
|
{
|
||||||
|
var enough =
|
||||||
|
element.AmountMode == JunctionAmountMode.Count
|
||||||
|
? units.Count >= Math.Max(1, element.AmountValue)
|
||||||
|
// Последняя единица входит целиком: рекламный блок не разрезается.
|
||||||
|
: accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue));
|
||||||
|
if (enough)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var unit = element.Units[(start + taken) % element.Units.Count];
|
||||||
|
if (unit.Duration <= TimeSpan.Zero)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
units.Add(unit);
|
||||||
|
accumulated += unit.Duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JunctionInsert(element, units, accumulated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ужимает стык под <paramref name="available"/>: с конца отбрасываются сначала лишние единицы
|
||||||
|
/// необязательных врезок, потом они сами целиком, и только если этого не хватило — обязательные.
|
||||||
|
///
|
||||||
|
/// Ужимаем поштучно, а не целыми врезками: рекламный блок из четырёх роликов, для которого до
|
||||||
|
/// якоря осталось место под один, должен поставить один, а не пропасть целиком.
|
||||||
|
/// </summary>
|
||||||
|
private static List<JunctionInsert> Trim(List<JunctionInsert> inserts, TimeSpan available)
|
||||||
|
{
|
||||||
|
var kept = inserts.Where(i => i.Duration > TimeSpan.Zero).ToList();
|
||||||
|
var total = kept.Aggregate(TimeSpan.Zero, (sum, i) => sum + i.Duration);
|
||||||
|
|
||||||
|
foreach (var required in new[] { false, true })
|
||||||
|
{
|
||||||
|
for (var i = kept.Count - 1; i >= 0 && total > available; i--)
|
||||||
|
{
|
||||||
|
if (kept[i].Element.IsRequired != required)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
while (total > available && kept[i].Units.Count > 1)
|
||||||
|
{
|
||||||
|
var units = kept[i].Units.Take(kept[i].Units.Count - 1).ToList();
|
||||||
|
var duration = units.Aggregate(TimeSpan.Zero, (sum, u) => sum + u.Duration);
|
||||||
|
total -= kept[i].Duration - duration;
|
||||||
|
kept[i] = kept[i] with { Units = units, Duration = duration };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total <= available)
|
||||||
|
break;
|
||||||
|
|
||||||
|
total -= kept[i].Duration;
|
||||||
|
kept.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Резервирует место под заставку. Ассет пуст: он рендерится под конкретную пару «из/в» уже после
|
/// Резервирует место под заставку. Ассет пуст: он рендерится под конкретную пару «из/в» уже после
|
||||||
/// того, как лента собрана, — до наполнения слотов пара попросту неизвестна.
|
/// того, как лента собрана, — до наполнения слотов пара попросту неизвестна.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static DateTimeOffset PlaceBumper(
|
private static DateTimeOffset PlaceBumper(
|
||||||
PlanningJunctionElement element,
|
JunctionInsert insert,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
DateTimeOffset limit,
|
DateTimeOffset limit,
|
||||||
JunctionPlacement placement,
|
JunctionPlacement placement,
|
||||||
@@ -92,10 +281,10 @@ public static class JunctionFiller
|
|||||||
PlanTrace? trace
|
PlanTrace? trace
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (element.BumperDuration <= TimeSpan.Zero || cursor + element.BumperDuration > limit)
|
if (insert.Duration <= TimeSpan.Zero || cursor + insert.Duration > limit)
|
||||||
return cursor;
|
return cursor;
|
||||||
|
|
||||||
var end = cursor + element.BumperDuration;
|
var end = cursor + insert.Duration;
|
||||||
items.Add(
|
items.Add(
|
||||||
new PlannedItem(
|
new PlannedItem(
|
||||||
Guid.Empty,
|
Guid.Empty,
|
||||||
@@ -106,17 +295,18 @@ public static class JunctionFiller
|
|||||||
placement.SlotId,
|
placement.SlotId,
|
||||||
PlannedItemKind.Bumper,
|
PlannedItemKind.Bumper,
|
||||||
trace,
|
trace,
|
||||||
element.BumperTemplateId,
|
insert.Element.BumperTemplateId,
|
||||||
placement.FromShowId,
|
placement.FromShowId,
|
||||||
placement.ToShowId
|
placement.ToShowId,
|
||||||
|
insert.Element.BumperVariantId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return end;
|
return end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ставит единицы врезки по её бюджету: N штук либо пока не наберётся M минут.</summary>
|
/// <summary>Ставит подобранные единицы врезки; те, что не влезают до предела, отбрасываются.</summary>
|
||||||
private static DateTimeOffset PlaceUnits(
|
private static DateTimeOffset PlaceUnits(
|
||||||
PlanningJunctionElement element,
|
JunctionInsert insert,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
DateTimeOffset limit,
|
DateTimeOffset limit,
|
||||||
Guid slotId,
|
Guid slotId,
|
||||||
@@ -124,34 +314,16 @@ public static class JunctionFiller
|
|||||||
PlanTrace? trace
|
PlanTrace? trace
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (element.Units.Count == 0)
|
var kind = insert.Element.Kind switch
|
||||||
return cursor;
|
|
||||||
|
|
||||||
var kind = element.Kind switch
|
|
||||||
{
|
{
|
||||||
JunctionElementKind.Ad => PlannedItemKind.Ad,
|
JunctionElementKind.Ad => PlannedItemKind.Ad,
|
||||||
JunctionElementKind.Promo => PlannedItemKind.Promo,
|
JunctionElementKind.Promo => PlannedItemKind.Promo,
|
||||||
_ => PlannedItemKind.Fallback,
|
_ => PlannedItemKind.Fallback,
|
||||||
};
|
};
|
||||||
|
|
||||||
var placed = 0;
|
foreach (var unit in insert.Units)
|
||||||
var accumulated = TimeSpan.Zero;
|
|
||||||
var index = 0;
|
|
||||||
|
|
||||||
while (index < element.Units.Count)
|
|
||||||
{
|
{
|
||||||
var unit = element.Units[index];
|
if (cursor + unit.Duration > limit)
|
||||||
index++;
|
|
||||||
|
|
||||||
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > limit)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var enough =
|
|
||||||
element.AmountMode == JunctionAmountMode.Count
|
|
||||||
? placed >= Math.Max(1, element.AmountValue)
|
|
||||||
// Последняя единица входит целиком: рекламный блок не разрезается.
|
|
||||||
: accumulated >= TimeSpan.FromMinutes(Math.Max(1, element.AmountValue));
|
|
||||||
if (enough)
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
items.Add(
|
items.Add(
|
||||||
@@ -167,8 +339,6 @@ public static class JunctionFiller
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
cursor += unit.Duration;
|
cursor += unit.Duration;
|
||||||
accumulated += unit.Duration;
|
|
||||||
placed++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cursor;
|
return cursor;
|
||||||
|
|||||||
@@ -85,11 +85,19 @@ public sealed record PlanningSlot(
|
|||||||
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
|
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Окно времени суток в часах канала; допускает переход через полночь (22:00 → 06:00).</summary>
|
||||||
|
public sealed record PlanningTimeWindow(TimeOnly From, TimeOnly To)
|
||||||
|
{
|
||||||
|
public bool Contains(TimeOnly moment) =>
|
||||||
|
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
|
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
|
||||||
/// решить, сколько их поставить и влезают ли они.
|
/// решить, сколько их поставить и влезают ли они.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PlanningJunctionElement(
|
public sealed record PlanningJunctionElement(
|
||||||
|
Guid ElementId,
|
||||||
JunctionElementKind Kind,
|
JunctionElementKind Kind,
|
||||||
IReadOnlyList<PlanningUnit> Units,
|
IReadOnlyList<PlanningUnit> Units,
|
||||||
JunctionAmountMode AmountMode,
|
JunctionAmountMode AmountMode,
|
||||||
@@ -99,8 +107,17 @@ public sealed record PlanningJunctionElement(
|
|||||||
bool OnlyOnElementChange = false,
|
bool OnlyOnElementChange = false,
|
||||||
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
||||||
int MinMinutesBetween = 0,
|
int MinMinutesBetween = 0,
|
||||||
|
/// <summary>Вероятность показа в процентах (100 — всегда).</summary>
|
||||||
|
int Chance = 100,
|
||||||
|
/// <summary>Окно времени суток, вне которого врезка не ставится (null — всегда).</summary>
|
||||||
|
PlanningTimeWindow? TimeWindow = null,
|
||||||
|
/// <summary>Метка развилки: из врезок с одной меткой ставится одна, выбранная по весам.</summary>
|
||||||
|
string? ChoiceKey = null,
|
||||||
|
int ChoiceWeight = 1,
|
||||||
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
|
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
|
||||||
Guid? BumperTemplateId = null,
|
Guid? BumperTemplateId = null,
|
||||||
|
/// <summary>Конкретный подблок заставки; null — выберет резолвер по триггеру и весам.</summary>
|
||||||
|
Guid? BumperVariantId = null,
|
||||||
/// <summary>Длительность резерва под заставку.</summary>
|
/// <summary>Длительность резерва под заставку.</summary>
|
||||||
TimeSpan BumperDuration = default
|
TimeSpan BumperDuration = default
|
||||||
);
|
);
|
||||||
@@ -108,7 +125,9 @@ public sealed record PlanningJunctionElement(
|
|||||||
/// <summary>Стык: последовательность врезок между программами.</summary>
|
/// <summary>Стык: последовательность врезок между программами.</summary>
|
||||||
public sealed record PlanningJunction(
|
public sealed record PlanningJunction(
|
||||||
Guid JunctionId,
|
Guid JunctionId,
|
||||||
IReadOnlyList<PlanningJunctionElement> Elements
|
IReadOnlyList<PlanningJunctionElement> Elements,
|
||||||
|
/// <summary>Потолок длины стыка целиком (null — ограничен только якорем).</summary>
|
||||||
|
TimeSpan? MaxTotal = null
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Полный вход одного прогона генератора.</summary>
|
/// <summary>Полный вход одного прогона генератора.</summary>
|
||||||
@@ -119,7 +138,9 @@ public sealed record PlanningInput(
|
|||||||
IReadOnlyList<PlanningSlot> Slots,
|
IReadOnlyList<PlanningSlot> Slots,
|
||||||
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
|
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
|
||||||
IReadOnlyList<PlanningUnit> FallbackUnits,
|
IReadOnlyList<PlanningUnit> FallbackUnits,
|
||||||
int SegmentSeconds
|
int SegmentSeconds,
|
||||||
|
/// <summary>Смещение времени канала от UTC — по нему считаются окна суток у врезок стыка.</summary>
|
||||||
|
int UtcOffsetMinutes = 0
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
|
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
|
||||||
@@ -136,6 +157,8 @@ public sealed record PlannedItem(
|
|||||||
Guid? BumperTemplateId = null,
|
Guid? BumperTemplateId = null,
|
||||||
Guid? FromShowId = null,
|
Guid? FromShowId = null,
|
||||||
Guid? ToShowId = null,
|
Guid? ToShowId = null,
|
||||||
|
/// <summary>Подблок заставки, если врезка задала его жёстко; null — выберет резолвер.</summary>
|
||||||
|
Guid? BumperVariantId = null,
|
||||||
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
|
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
|
||||||
Guid? CollectionId = null
|
Guid? CollectionId = null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ public static class SchedulePlanner
|
|||||||
public List<PlanningWarning> Warnings { get; } = [];
|
public List<PlanningWarning> Warnings { get; } = [];
|
||||||
public JunctionHistory Junctions { get; } = new();
|
public JunctionHistory Junctions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Смещение времени канала — врезки со своим окном суток считают его по нему.</summary>
|
||||||
|
public TimeSpan ChannelOffset { get; } = TimeSpan.FromMinutes(input.UtcOffsetMinutes);
|
||||||
|
|
||||||
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
|
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
|
||||||
public Guid? PreviousShowId { get; set; }
|
public Guid? PreviousShowId { get; set; }
|
||||||
}
|
}
|
||||||
@@ -254,9 +257,11 @@ public static class SchedulePlanner
|
|||||||
slot.SlotId,
|
slot.SlotId,
|
||||||
run.PreviousShowId,
|
run.PreviousShowId,
|
||||||
unit.ShowId,
|
unit.ShowId,
|
||||||
ElementChanged: run.PreviousShowId != unit.ShowId
|
ElementChanged: run.PreviousShowId != unit.ShowId,
|
||||||
|
run.ChannelOffset
|
||||||
),
|
),
|
||||||
run.Junctions,
|
run.Junctions,
|
||||||
|
run.Random,
|
||||||
run.Items,
|
run.Items,
|
||||||
slotTrace
|
slotTrace
|
||||||
);
|
);
|
||||||
@@ -282,8 +287,15 @@ public static class SchedulePlanner
|
|||||||
slot.JunctionAfter,
|
slot.JunctionAfter,
|
||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
|
new JunctionPlacement(
|
||||||
|
slot.SlotId,
|
||||||
|
run.PreviousShowId,
|
||||||
|
null,
|
||||||
|
ElementChanged: true,
|
||||||
|
run.ChannelOffset
|
||||||
|
),
|
||||||
run.Junctions,
|
run.Junctions,
|
||||||
|
run.Random,
|
||||||
run.Items,
|
run.Items,
|
||||||
slotTrace
|
slotTrace
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ using System.Globalization;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
using static TeleWave.Infrastructure.Media.FfmpegText;
|
using static TeleWave.Infrastructure.Media.FfmpegText;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Media;
|
namespace TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Синтезирует ТВ-заставку перехода полностью на ffmpeg (без исходного файла) по
|
/// Синтезирует ТВ-заставку перехода полностью на ffmpeg (без исходного файла) по
|
||||||
/// <see cref="BumperRenderSpec"/>: анимированный градиентный фон + текст «Сейчас/Далее» + короткий
|
/// <see cref="BumperRenderSpec"/>: анимированный градиентный фон + строки текста + короткий джингл,
|
||||||
/// джингл, и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и
|
/// и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и
|
||||||
/// кратная сегменту, поэтому эфирная математика не отличает заставку от программы.
|
/// кратная сегменту, поэтому эфирная математика не отличает заставку от программы.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FfmpegBumperRenderer(
|
public sealed class FfmpegBumperRenderer(
|
||||||
@@ -22,21 +23,19 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
private readonly MediaOptions _media = mediaOptions.Value;
|
private readonly MediaOptions _media = mediaOptions.Value;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Файлы с динамическим текстом заставки. Всё пользователь-редактируемое (названия шоу, подписи,
|
/// Одна строка, разложенная под drawtext: файл с текстом, размер, цвет и вертикальная позиция.
|
||||||
/// свободные строки) ffmpeg читает через <c>textfile=</c> с <c>expansion=none</c>: иначе запятая,
|
/// Текст ffmpeg читает через <c>textfile=</c> с <c>expansion=none</c>: иначе запятая, <c>;</c>,
|
||||||
/// <c>;</c>, <c>[</c> или <c>]</c> в тексте ломают (или инъектируют звенья в) цепочку
|
/// <c>[</c> или <c>]</c> в тексте ломают (или инъектируют звенья в) цепочку <c>-filter_complex</c>.
|
||||||
/// <c>-filter_complex</c>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed record TextFiles(string Now, string Next, string NowLabel, string NextLabel)
|
private sealed record LayoutLine(
|
||||||
{
|
string File,
|
||||||
public static TextFiles In(string assetDir) =>
|
string Text,
|
||||||
new(
|
int FontSize,
|
||||||
Path.Combine(assetDir, "now.txt"),
|
string Color,
|
||||||
Path.Combine(assetDir, "next.txt"),
|
int Y,
|
||||||
Path.Combine(assetDir, "nowlabel.txt"),
|
bool Shadow,
|
||||||
Path.Combine(assetDir, "nextlabel.txt")
|
double FadeStart
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<BumperRenderResult> RenderAsync(
|
public async Task<BumperRenderResult> RenderAsync(
|
||||||
Guid assetId,
|
Guid assetId,
|
||||||
@@ -53,33 +52,18 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
Directory.Delete(assetDir, recursive: true);
|
Directory.Delete(assetDir, recursive: true);
|
||||||
Directory.CreateDirectory(assetDir);
|
Directory.CreateDirectory(assetDir);
|
||||||
|
|
||||||
// Названия шоу / свободные строки.
|
var layout = Layout(assetDir, spec);
|
||||||
var text = TextFiles.In(assetDir);
|
foreach (var line in layout)
|
||||||
var line1 = spec.FreeText ? spec.FreeLine1 : spec.NowTitle;
|
|
||||||
var line2 = spec.FreeText ? spec.FreeLine2 : spec.NextTitle;
|
|
||||||
await File.WriteAllTextAsync(text.Now, line1, new UTF8Encoding(false), cancellationToken);
|
|
||||||
await File.WriteAllTextAsync(text.Next, line2, new UTF8Encoding(false), cancellationToken);
|
|
||||||
|
|
||||||
// Подписи «Сейчас/Далее» нужны лишь в одноимённом режиме — в FreeText их не рисуют.
|
|
||||||
if (!spec.FreeText)
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(
|
await File.WriteAllTextAsync(
|
||||||
text.NowLabel,
|
line.File,
|
||||||
spec.NowLabel,
|
line.Text,
|
||||||
new UTF8Encoding(false),
|
new UTF8Encoding(false),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
await File.WriteAllTextAsync(
|
|
||||||
text.NextLabel,
|
|
||||||
spec.NextLabel,
|
|
||||||
new UTF8Encoding(false),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var args = BuildArgs(assetDir, seg, target, text, spec);
|
var args = BuildArgs(assetDir, seg, target, layout, spec);
|
||||||
var result = await ProcessRunner.RunAsync(
|
var result = await ProcessRunner.RunAsync(
|
||||||
_media.FfmpegPath,
|
_media.FfmpegPath,
|
||||||
args,
|
args,
|
||||||
@@ -115,36 +99,83 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
TryDelete(text.Now);
|
foreach (var line in layout)
|
||||||
TryDelete(text.Next);
|
TryDelete(line.File);
|
||||||
TryDelete(text.NowLabel);
|
|
||||||
TryDelete(text.NextLabel);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Раскладывает строки по кадру: блок центрируется целиком по вертикали, размер зависит от роли
|
||||||
|
/// и ужимается под ширину, строки проявляются по очереди. Фиксированных мест у ролей нет —
|
||||||
|
/// иначе набор из двух строк висел бы в верхней трети кадра, как это было у «Сейчас/Далее».
|
||||||
|
/// </summary>
|
||||||
|
private static List<LayoutLine> Layout(string assetDir, BumperRenderSpec spec)
|
||||||
|
{
|
||||||
|
var h = spec.Height;
|
||||||
|
var titleSize = Math.Max(24, h / 10);
|
||||||
|
var labelSize = Math.Max(14, h / 22);
|
||||||
|
var captionSize = Math.Max(12, h / 28);
|
||||||
|
var textWidth = (int)(spec.Width * 0.92); // Поля по 4% с каждой стороны.
|
||||||
|
|
||||||
|
var sizes = spec
|
||||||
|
.Lines.Select(line =>
|
||||||
|
{
|
||||||
|
var baseSize = line.Style switch
|
||||||
|
{
|
||||||
|
BumperLineStyle.Title => titleSize,
|
||||||
|
BumperLineStyle.Caption => captionSize,
|
||||||
|
_ => labelSize,
|
||||||
|
};
|
||||||
|
return FitSize(line.Text, baseSize, textWidth);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Между подписью и её названием зазор меньше, чем между смысловыми блоками.
|
||||||
|
var gaps = new List<int>();
|
||||||
|
for (var i = 1; i < spec.Lines.Count; i++)
|
||||||
|
gaps.Add(
|
||||||
|
spec.Lines[i - 1].Style == BumperLineStyle.Label
|
||||||
|
? (int)(sizes[i] * 0.25)
|
||||||
|
: (int)(sizes[i] * 0.8)
|
||||||
|
);
|
||||||
|
|
||||||
|
var totalHeight = sizes.Sum(s => (int)(s * 1.2)) + gaps.Sum();
|
||||||
|
var y = (h - totalHeight) / 2;
|
||||||
|
|
||||||
|
var layout = new List<LayoutLine>(spec.Lines.Count);
|
||||||
|
for (var i = 0; i < spec.Lines.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
y += (int)(sizes[i - 1] * 1.2) + gaps[i - 1];
|
||||||
|
|
||||||
|
var line = spec.Lines[i];
|
||||||
|
layout.Add(
|
||||||
|
new LayoutLine(
|
||||||
|
Path.Combine(assetDir, $"line{i}.txt"),
|
||||||
|
line.Text,
|
||||||
|
sizes[i],
|
||||||
|
line.Color == BumperLineColor.Accent ? spec.AccentColor : spec.TextColor,
|
||||||
|
y,
|
||||||
|
line.Style == BumperLineStyle.Title,
|
||||||
|
0.2 + i * 0.3
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
private List<string> BuildArgs(
|
private List<string> BuildArgs(
|
||||||
string assetDir,
|
string assetDir,
|
||||||
int seg,
|
int seg,
|
||||||
int target,
|
int target,
|
||||||
TextFiles text,
|
IReadOnlyList<LayoutLine> layout,
|
||||||
BumperRenderSpec spec
|
BumperRenderSpec spec
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var w = spec.Width;
|
var w = spec.Width;
|
||||||
var h = spec.Height;
|
var h = spec.Height;
|
||||||
|
|
||||||
var titleSize = Math.Max(24, h / 10);
|
|
||||||
var labelSize = Math.Max(14, h / 22);
|
|
||||||
var gap = (int)(labelSize * 1.4);
|
|
||||||
|
|
||||||
// Доступная ширина под текст (поля по 4% с каждой стороны) — под неё ужимаем длинные строки.
|
|
||||||
var textWidth = (int)(w * 0.92);
|
|
||||||
|
|
||||||
var nowLabelY = (int)(h * 0.22);
|
|
||||||
var nowTitleY = nowLabelY + gap;
|
|
||||||
var nextLabelY = (int)(h * 0.60);
|
|
||||||
var nextTitleY = nextLabelY + gap;
|
|
||||||
|
|
||||||
var font = EscapePath(spec.FontFile);
|
var font = EscapePath(spec.FontFile);
|
||||||
var outStart = Math.Max(0, target - 1);
|
var outStart = Math.Max(0, target - 1);
|
||||||
|
|
||||||
@@ -200,41 +231,8 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var vchain = new StringBuilder(videoPrefix);
|
var vchain = new StringBuilder(videoPrefix);
|
||||||
if (spec.FreeText)
|
foreach (var line in layout)
|
||||||
{
|
vchain.Append(',').Append(DrawLine(font, line));
|
||||||
// Свободный текст: две центрированные строки (акцентная + основная), ужатые под ширину кадра.
|
|
||||||
var line1Size = FitSize(spec.FreeLine1, labelSize + 4, textWidth);
|
|
||||||
var line2Size = FitSize(spec.FreeLine2, titleSize, textWidth);
|
|
||||||
var line1Y = (int)(h * 0.40);
|
|
||||||
var line2Y = line1Y + (int)(line2Size * 1.2);
|
|
||||||
vchain
|
|
||||||
.Append(',')
|
|
||||||
.Append(DrawTitle(font, text.Now, spec.AccentColor, line1Size, line1Y, 0.2));
|
|
||||||
vchain
|
|
||||||
.Append(',')
|
|
||||||
.Append(DrawTitle(font, text.Next, spec.TextColor, line2Size, line2Y, 0.5));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var nowSize = FitSize(spec.NowTitle, titleSize, textWidth);
|
|
||||||
var nextSize = FitSize(spec.NextTitle, titleSize, textWidth);
|
|
||||||
vchain
|
|
||||||
.Append(',')
|
|
||||||
.Append(
|
|
||||||
DrawLabel(font, text.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2)
|
|
||||||
);
|
|
||||||
vchain
|
|
||||||
.Append(',')
|
|
||||||
.Append(DrawTitle(font, text.Now, spec.TextColor, nowSize, nowTitleY, 0.3));
|
|
||||||
vchain
|
|
||||||
.Append(',')
|
|
||||||
.Append(
|
|
||||||
DrawLabel(font, text.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0)
|
|
||||||
);
|
|
||||||
vchain
|
|
||||||
.Append(',')
|
|
||||||
.Append(DrawTitle(font, text.Next, spec.TextColor, nextSize, nextTitleY, 1.1));
|
|
||||||
}
|
|
||||||
vchain.Append("[v]");
|
vchain.Append("[v]");
|
||||||
|
|
||||||
var filterComplex = $"{vchain};{audioChain}";
|
var filterComplex = $"{vchain};{audioChain}";
|
||||||
@@ -304,33 +302,20 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
private static bool IsImage(string path) =>
|
private static bool IsImage(string path) =>
|
||||||
ImageExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
|
ImageExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
|
||||||
|
|
||||||
private static string DrawTitle(
|
/// <summary>
|
||||||
string font,
|
/// Звено drawtext для одной строки. Текст читается из файла (textfile=) с expansion=none —
|
||||||
string textFile,
|
/// произвольные символы не могут сломать/инъектировать цепочку filter_complex.
|
||||||
string color,
|
/// </summary>
|
||||||
int size,
|
private static string DrawLine(string font, LayoutLine line)
|
||||||
int y,
|
{
|
||||||
double fadeStart
|
var shadow = line.Shadow
|
||||||
) =>
|
? ":shadowcolor=black@0.6:shadowx=2:shadowy=2"
|
||||||
$"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none"
|
: ":shadowcolor=black@0.6:shadowx=1:shadowy=1";
|
||||||
+ $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}"
|
return $"drawtext=fontfile={font}:textfile={EscapePath(line.File)}:expansion=none"
|
||||||
+ ":shadowcolor=black@0.6:shadowx=2:shadowy=2"
|
+ $":fontcolor={line.Color}:fontsize={line.FontSize}:x=(w-text_w)/2:y={line.Y}"
|
||||||
+ $":alpha='{FadeExpr(fadeStart)}'";
|
+ shadow
|
||||||
|
+ $":alpha='{FadeExpr(line.FadeStart)}'";
|
||||||
private static string DrawLabel(
|
}
|
||||||
string font,
|
|
||||||
string textFile,
|
|
||||||
string color,
|
|
||||||
int size,
|
|
||||||
int y,
|
|
||||||
double fadeStart
|
|
||||||
) =>
|
|
||||||
// Подпись читается из файла (textfile=) с expansion=none — произвольные символы подписи
|
|
||||||
// не могут сломать/инъектировать цепочку filter_complex (см. запись файлов в RenderAsync).
|
|
||||||
$"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none"
|
|
||||||
+ $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}"
|
|
||||||
+ ":shadowcolor=black@0.6:shadowx=1:shadowy=1"
|
|
||||||
+ $":alpha='{FadeExpr(fadeStart)}'";
|
|
||||||
|
|
||||||
private static string FadeExpr(double start) =>
|
private static string FadeExpr(double start) =>
|
||||||
$"if(lt(t,{Fmt(start)}),0,min(1,(t-{Fmt(start)})/0.5))";
|
$"if(lt(t,{Fmt(start)}),0,min(1,(t-{Fmt(start)})/0.5))";
|
||||||
|
|||||||
+1422
File diff suppressed because it is too large
Load Diff
+465
@@ -0,0 +1,465 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SharedJunctionsAndBumperLines : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Перенос данных идёт до всех схемных операций: старые колонки ещё на месте.
|
||||||
|
// Текст подблоков превращается в строки, «Сейчас/Далее» — в четыре строки с
|
||||||
|
// плейсхолдерами, свободный текст — в две.
|
||||||
|
migrationBuilder.Sql("""ALTER TABLE "BumperTextVariants" ADD COLUMN "Lines" jsonb;""");
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE "BumperTextVariants" SET "Lines" = CASE WHEN "Kind" = 1 THEN
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('Position', 0, 'Style', 0, 'Color', 0, 'Text', COALESCE("Line1", '')),
|
||||||
|
jsonb_build_object('Position', 1, 'Style', 1, 'Color', 1, 'Text', COALESCE("Line2", ''))
|
||||||
|
)
|
||||||
|
ELSE
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('Position', 0, 'Style', 0, 'Color', 0, 'Text', COALESCE("NowLabel", '')),
|
||||||
|
jsonb_build_object('Position', 1, 'Style', 1, 'Color', 1, 'Text', '{now.title}'),
|
||||||
|
jsonb_build_object('Position', 2, 'Style', 0, 'Color', 0, 'Text', COALESCE("NextLabel", '')),
|
||||||
|
jsonb_build_object('Position', 3, 'Style', 1, 'Color', 1, 'Text', '{next.title}')
|
||||||
|
)
|
||||||
|
END;
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
// Пустые строки выбрасываем и перенумеровываем — иначе в кадре останутся дыры.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE "BumperTextVariants" v SET "Lines" = f.lines
|
||||||
|
FROM (
|
||||||
|
SELECT id, COALESCE(jsonb_agg(jsonb_set(elem, '{Position}', to_jsonb(rn - 1)) ORDER BY rn), '[]'::jsonb) AS lines
|
||||||
|
FROM (
|
||||||
|
SELECT t."Id" AS id, e.elem AS elem,
|
||||||
|
row_number() OVER (PARTITION BY t."Id" ORDER BY e.ord) AS rn
|
||||||
|
FROM "BumperTextVariants" t,
|
||||||
|
LATERAL jsonb_array_elements(t."Lines") WITH ORDINALITY AS e(elem, ord)
|
||||||
|
WHERE COALESCE(e.elem->>'Text', '') <> ''
|
||||||
|
) x GROUP BY id
|
||||||
|
) f WHERE v."Id" = f.id;
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
// Фон: «Сейчас/Далее» показывал постер следующего шоу, свободный текст — фон блока.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""ALTER TABLE "BumperTextVariants" ADD COLUMN "Background" integer NOT NULL DEFAULT 0;"""
|
||||||
|
);
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""UPDATE "BumperTextVariants" SET "Background" = CASE WHEN "Kind" = 1 THEN 0 ELSE 1 END;"""
|
||||||
|
);
|
||||||
|
|
||||||
|
// Шрифт переезжает с канала на блок — у блока он был канальным.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""ALTER TABLE "BumperTemplate" ADD COLUMN "Font" integer NOT NULL DEFAULT 0;"""
|
||||||
|
);
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""UPDATE "BumperTemplate" t SET "Font" = c."BumperFont" FROM "Channels" c WHERE c."Id" = t."ChannelId";"""
|
||||||
|
);
|
||||||
|
|
||||||
|
// Кэш заставок: сигнатура теперь считается по содержимому, а подставленного текста
|
||||||
|
// у старых записей нет. Это кэш — он пересоберётся при ближайшей генерации.
|
||||||
|
migrationBuilder.Sql("""DELETE FROM "BumperAssets";""");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_BumperTemplate_Channels_ChannelId",
|
||||||
|
table: "BumperTemplate"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
|
||||||
|
table: "BumperTextVariants"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_JunctionTemplates_ChannelId",
|
||||||
|
table: "JunctionTemplates"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
|
||||||
|
table: "BumperAssets"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(name: "PK_BumperTemplate", table: "BumperTemplate");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_BumperTemplate_ChannelId_Position",
|
||||||
|
table: "BumperTemplate"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "ChannelId", table: "JunctionTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "BumperFont", table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "BumperSelection", table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "BumpersEnabled", table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "Line1", table: "BumperTextVariants");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "Line2", table: "BumperTextVariants");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "NextLabel", table: "BumperTextVariants");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "NowLabel", table: "BumperTextVariants");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "FromShowId", table: "BumperAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "ToShowId", table: "BumperAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperTemplate");
|
||||||
|
|
||||||
|
migrationBuilder.RenameTable(name: "BumperTemplate", newName: "BumperTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "Kind", table: "BumperTextVariants");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "Position", table: "BumperTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MaxTotalSeconds",
|
||||||
|
table: "JunctionTemplates",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "BumperVariantId",
|
||||||
|
table: "JunctionElements",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ChoiceKey",
|
||||||
|
table: "JunctionElements",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ChoiceWeight",
|
||||||
|
table: "JunctionElements",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "PosterShowId",
|
||||||
|
table: "BumperAssets",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RenderedLinesJson",
|
||||||
|
table: "BumperAssets",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "[]"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_BumperTemplates",
|
||||||
|
table: "BumperTemplates",
|
||||||
|
column: "Id"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JunctionTemplates_Name",
|
||||||
|
table: "JunctionTemplates",
|
||||||
|
column: "Name"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JunctionElements_BumperTemplateId",
|
||||||
|
table: "JunctionElements",
|
||||||
|
column: "BumperTemplateId"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JunctionElements_BumperVariantId",
|
||||||
|
table: "JunctionElements",
|
||||||
|
column: "BumperVariantId"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BumperAssets_MediaAssetId",
|
||||||
|
table: "BumperAssets",
|
||||||
|
column: "MediaAssetId"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BumperAssets_Signature",
|
||||||
|
table: "BumperAssets",
|
||||||
|
column: "Signature",
|
||||||
|
unique: true
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BumperTemplates_Name",
|
||||||
|
table: "BumperTemplates",
|
||||||
|
column: "Name"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_BumperTextVariants_BumperTemplates_BumperTemplateId",
|
||||||
|
table: "BumperTextVariants",
|
||||||
|
column: "BumperTemplateId",
|
||||||
|
principalTable: "BumperTemplates",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_JunctionElements_BumperTemplates_BumperTemplateId",
|
||||||
|
table: "JunctionElements",
|
||||||
|
column: "BumperTemplateId",
|
||||||
|
principalTable: "BumperTemplates",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_JunctionElements_BumperTextVariants_BumperVariantId",
|
||||||
|
table: "JunctionElements",
|
||||||
|
column: "BumperVariantId",
|
||||||
|
principalTable: "BumperTextVariants",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_BumperTextVariants_BumperTemplates_BumperTemplateId",
|
||||||
|
table: "BumperTextVariants"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_JunctionElements_BumperTemplates_BumperTemplateId",
|
||||||
|
table: "JunctionElements"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_JunctionElements_BumperTextVariants_BumperVariantId",
|
||||||
|
table: "JunctionElements"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_JunctionTemplates_Name",
|
||||||
|
table: "JunctionTemplates"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_JunctionElements_BumperTemplateId",
|
||||||
|
table: "JunctionElements"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_JunctionElements_BumperVariantId",
|
||||||
|
table: "JunctionElements"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(name: "IX_BumperAssets_MediaAssetId", table: "BumperAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(name: "IX_BumperAssets_Signature", table: "BumperAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(name: "PK_BumperTemplates", table: "BumperTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(name: "IX_BumperTemplates_Name", table: "BumperTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "MaxTotalSeconds", table: "JunctionTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "BumperVariantId", table: "JunctionElements");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "ChoiceKey", table: "JunctionElements");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "ChoiceWeight", table: "JunctionElements");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "Lines", table: "BumperTextVariants");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "PosterShowId", table: "BumperAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "RenderedLinesJson", table: "BumperAssets");
|
||||||
|
|
||||||
|
migrationBuilder.RenameTable(name: "BumperTemplates", newName: "BumperTemplate");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "Background", table: "BumperTextVariants");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Kind",
|
||||||
|
table: "BumperTextVariants",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "Font", table: "BumperTemplate");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Position",
|
||||||
|
table: "BumperTemplate",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ChannelId",
|
||||||
|
table: "JunctionTemplates",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "BumperFont",
|
||||||
|
table: "Channels",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "BumperSelection",
|
||||||
|
table: "Channels",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "BumpersEnabled",
|
||||||
|
table: "Channels",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Line1",
|
||||||
|
table: "BumperTextVariants",
|
||||||
|
type: "character varying(120)",
|
||||||
|
maxLength: 120,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Line2",
|
||||||
|
table: "BumperTextVariants",
|
||||||
|
type: "character varying(120)",
|
||||||
|
maxLength: 120,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NextLabel",
|
||||||
|
table: "BumperTextVariants",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NowLabel",
|
||||||
|
table: "BumperTextVariants",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ChannelId",
|
||||||
|
table: "BumperAssets",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "FromShowId",
|
||||||
|
table: "BumperAssets",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ToShowId",
|
||||||
|
table: "BumperAssets",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ChannelId",
|
||||||
|
table: "BumperTemplate",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_BumperTemplate",
|
||||||
|
table: "BumperTemplate",
|
||||||
|
column: "Id"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JunctionTemplates_ChannelId",
|
||||||
|
table: "JunctionTemplates",
|
||||||
|
column: "ChannelId"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
|
||||||
|
table: "BumperAssets",
|
||||||
|
columns: new[] { "FromShowId", "ToShowId", "Signature" }
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BumperTemplate_ChannelId_Position",
|
||||||
|
table: "BumperTemplate",
|
||||||
|
columns: new[] { "ChannelId", "Position" }
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_BumperTemplate_Channels_ChannelId",
|
||||||
|
table: "BumperTemplate",
|
||||||
|
column: "ChannelId",
|
||||||
|
principalTable: "Channels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
|
||||||
|
table: "BumperTextVariants",
|
||||||
|
column: "BumperTemplateId",
|
||||||
|
principalTable: "BumperTemplate",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,18 +164,19 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<Guid>("ChannelId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<Guid>("FromShowId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("MediaAssetId")
|
b.Property<Guid>("MediaAssetId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PosterShowId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("RenderedLinesJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
b.Property<string>("Signature")
|
b.Property<string>("Signature")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
@@ -184,15 +185,15 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid>("TemplateId")
|
b.Property<Guid>("TemplateId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<Guid>("ToShowId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("VariantId")
|
b.Property<Guid>("VariantId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("FromShowId", "ToShowId", "Signature");
|
b.HasIndex("MediaAssetId");
|
||||||
|
|
||||||
|
b.HasIndex("Signature")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("BumperAssets");
|
b.ToTable("BumperAssets");
|
||||||
});
|
});
|
||||||
@@ -227,20 +228,17 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid?>("BackgroundImageId")
|
b.Property<Guid?>("BackgroundImageId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<Guid>("ChannelId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Font")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)");
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
b.Property<int>("Position")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("Revision")
|
b.Property<int>("Revision")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -251,9 +249,9 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ChannelId", "Position");
|
b.HasIndex("Name");
|
||||||
|
|
||||||
b.ToTable("BumperTemplate");
|
b.ToTable("BumperTemplates");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
|
||||||
@@ -261,40 +259,20 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Background")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<Guid>("BumperTemplateId")
|
b.Property<Guid>("BumperTemplateId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<int>("Kind")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("Line1")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(120)
|
|
||||||
.HasColumnType("character varying(120)");
|
|
||||||
|
|
||||||
b.Property<string>("Line2")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(120)
|
|
||||||
.HasColumnType("character varying(120)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)");
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
b.Property<string>("NextLabel")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(64)
|
|
||||||
.HasColumnType("character varying(64)");
|
|
||||||
|
|
||||||
b.Property<string>("NowLabel")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(64)
|
|
||||||
.HasColumnType("character varying(64)");
|
|
||||||
|
|
||||||
b.Property<int>("Position")
|
b.Property<int>("Position")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -321,15 +299,6 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<double>("AnalogFilterStrength")
|
b.Property<double>("AnalogFilterStrength")
|
||||||
.HasColumnType("double precision");
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<int>("BumperFont")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("BumperSelection")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<bool>("BumpersEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -886,6 +855,18 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid?>("BumperTemplateId")
|
b.Property<Guid?>("BumperTemplateId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("BumperVariantId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ChoiceKey")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int>("ChoiceWeight")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(1);
|
||||||
|
|
||||||
b.Property<string>("ConditionsJson")
|
b.Property<string>("ConditionsJson")
|
||||||
.HasColumnType("jsonb");
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
@@ -906,6 +887,10 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BumperTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("BumperVariantId");
|
||||||
|
|
||||||
b.HasIndex("GroupId");
|
b.HasIndex("GroupId");
|
||||||
|
|
||||||
b.HasIndex("JunctionTemplateId", "Position");
|
b.HasIndex("JunctionTemplateId", "Position");
|
||||||
@@ -918,12 +903,12 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<Guid>("ChannelId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxTotalSeconds")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
@@ -931,7 +916,7 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ChannelId");
|
b.HasIndex("Name");
|
||||||
|
|
||||||
b.ToTable("JunctionTemplates");
|
b.ToTable("JunctionTemplates");
|
||||||
});
|
});
|
||||||
@@ -1234,15 +1219,6 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
|
||||||
.WithMany("BumperTemplates")
|
|
||||||
.HasForeignKey("ChannelId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
|
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null)
|
b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null)
|
||||||
@@ -1250,6 +1226,37 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
.HasForeignKey("BumperTemplateId")
|
.HasForeignKey("BumperTemplateId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsMany("TeleWave.Domain.Broadcast.BumperLine", "Lines", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<Guid>("BumperTextVariantId");
|
||||||
|
|
||||||
|
b1.Property<int>("__synthesizedOrdinal")
|
||||||
|
.ValueGeneratedOnAdd();
|
||||||
|
|
||||||
|
b1.Property<int>("Color");
|
||||||
|
|
||||||
|
b1.Property<int>("Position");
|
||||||
|
|
||||||
|
b1.Property<int>("Style");
|
||||||
|
|
||||||
|
b1.Property<string>("Text")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120);
|
||||||
|
|
||||||
|
b1.HasKey("BumperTextVariantId", "__synthesizedOrdinal");
|
||||||
|
|
||||||
|
b1.ToTable("BumperTextVariants");
|
||||||
|
|
||||||
|
b1
|
||||||
|
.ToJson("Lines")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BumperTextVariantId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Lines");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b =>
|
modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b =>
|
||||||
@@ -1320,6 +1327,16 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b =>
|
modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BumperTemplateId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("TeleWave.Domain.Broadcast.BumperTextVariant", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BumperVariantId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasOne("TeleWave.Domain.Programming.Group", null)
|
b.HasOne("TeleWave.Domain.Programming.Group", null)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("GroupId")
|
.HasForeignKey("GroupId")
|
||||||
@@ -1360,11 +1377,6 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Navigation("Variants");
|
b.Navigation("Variants");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("BumperTemplates");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Library.Collection", b =>
|
modelBuilder.Entity("TeleWave.Domain.Library.Collection", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Items");
|
b.Navigation("Items");
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>();
|
public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>();
|
||||||
public DbSet<Channel> Channels => Set<Channel>();
|
public DbSet<Channel> Channels => Set<Channel>();
|
||||||
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
||||||
|
public DbSet<BumperTemplate> BumperTemplates => Set<BumperTemplate>();
|
||||||
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
|
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
|
||||||
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
|
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
|
||||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||||
|
|||||||
+4
-7
@@ -9,13 +9,10 @@ public class BumperAssetConfiguration : IEntityTypeConfiguration<BumperAsset>
|
|||||||
public void Configure(EntityTypeBuilder<BumperAsset> builder)
|
public void Configure(EntityTypeBuilder<BumperAsset> builder)
|
||||||
{
|
{
|
||||||
builder.Property(x => x.Signature).IsRequired().HasMaxLength(128);
|
builder.Property(x => x.Signature).IsRequired().HasMaxLength(128);
|
||||||
|
builder.Property(x => x.RenderedLinesJson).HasColumnType("jsonb");
|
||||||
|
|
||||||
// Кэш-ключ заставки: одна отрендеренная пара «из→в» при данной сигнатуре оформления.
|
// Кэш-ключ — сигнатура содержимого: одинаковая заставка на трёх каналах рендерится один раз.
|
||||||
builder.HasIndex(x => new
|
builder.HasIndex(x => x.Signature).IsUnique();
|
||||||
{
|
builder.HasIndex(x => x.MediaAssetId);
|
||||||
x.FromShowId,
|
|
||||||
x.ToShowId,
|
|
||||||
x.Signature,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-12
@@ -15,13 +15,6 @@ public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
|
|||||||
|
|
||||||
// Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно.
|
// Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно.
|
||||||
builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL");
|
builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL");
|
||||||
|
|
||||||
builder
|
|
||||||
.HasMany(x => x.BumperTemplates)
|
|
||||||
.WithOne()
|
|
||||||
.HasForeignKey(t => t.ChannelId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +22,7 @@ public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTempla
|
|||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
|
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
|
||||||
{
|
{
|
||||||
builder.HasIndex(x => new { x.ChannelId, x.Position });
|
builder.HasIndex(x => x.Name);
|
||||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
|
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
|
||||||
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
|
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
|
||||||
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
|
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
|
||||||
@@ -52,10 +45,18 @@ public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTex
|
|||||||
{
|
{
|
||||||
builder.HasIndex(x => new { x.BumperTemplateId, x.Position });
|
builder.HasIndex(x => new { x.BumperTemplateId, x.Position });
|
||||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
|
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
|
||||||
builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64);
|
|
||||||
builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64);
|
|
||||||
builder.Property(x => x.Line1).IsRequired().HasMaxLength(120);
|
|
||||||
builder.Property(x => x.Line2).IsRequired().HasMaxLength(120);
|
|
||||||
builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight);
|
builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight);
|
||||||
|
|
||||||
|
// Строки — одной jsonb-колонкой: они всегда читаются и пишутся вместе с подблоком,
|
||||||
|
// отдельная таблица дала бы join и порядковые правки там, где список заменяется целиком.
|
||||||
|
builder.OwnsMany(
|
||||||
|
x => x.Lines,
|
||||||
|
lines =>
|
||||||
|
{
|
||||||
|
lines.ToJson();
|
||||||
|
lines.Property(l => l.Text).HasMaxLength(120);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
builder.Navigation(x => x.Lines).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Persistence.Configurations;
|
namespace TeleWave.Infrastructure.Persistence.Configurations;
|
||||||
@@ -10,7 +11,7 @@ public class JunctionTemplateConfiguration : IEntityTypeConfiguration<JunctionTe
|
|||||||
public void Configure(EntityTypeBuilder<JunctionTemplate> builder)
|
public void Configure(EntityTypeBuilder<JunctionTemplate> builder)
|
||||||
{
|
{
|
||||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
builder.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
||||||
builder.HasIndex(x => x.ChannelId);
|
builder.HasIndex(x => x.Name);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.HasMany(x => x.Elements)
|
.HasMany(x => x.Elements)
|
||||||
@@ -27,6 +28,8 @@ public class JunctionElementConfiguration : IEntityTypeConfiguration<JunctionEle
|
|||||||
{
|
{
|
||||||
builder.HasIndex(x => new { x.JunctionTemplateId, x.Position });
|
builder.HasIndex(x => new { x.JunctionTemplateId, x.Position });
|
||||||
builder.Property(x => x.ConditionsJson).HasColumnType("jsonb");
|
builder.Property(x => x.ConditionsJson).HasColumnType("jsonb");
|
||||||
|
builder.Property(x => x.ChoiceKey).HasMaxLength(64);
|
||||||
|
builder.Property(x => x.ChoiceWeight).HasDefaultValue(JunctionElement.DefaultChoiceWeight);
|
||||||
|
|
||||||
// Группа не удаляется, пока на неё ссылается врезка: иначе стык молча перестал бы работать.
|
// Группа не удаляется, пока на неё ссылается врезка: иначе стык молча перестал бы работать.
|
||||||
builder
|
builder
|
||||||
@@ -34,5 +37,19 @@ public class JunctionElementConfiguration : IEntityTypeConfiguration<JunctionEle
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.GroupId)
|
.HasForeignKey(x => x.GroupId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// То же и с заставкой: блок общий, и удаление используемого выключило бы заставки в чужих
|
||||||
|
// каналах. Проверку дублирует хендлер — ради внятной ошибки вместо нарушения ссылки.
|
||||||
|
builder
|
||||||
|
.HasOne<BumperTemplate>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.BumperTemplateId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder
|
||||||
|
.HasOne<BumperTextVariant>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.BumperVariantId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using TeleWave.Application.Broadcast;
|
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
@@ -16,8 +15,8 @@ namespace TeleWave.Application.Tests.Broadcast;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Превью ТВ-заставки: рендерится каждый подблок блока, длительность выравнивается по длине
|
/// Превью ТВ-заставки: рендерится каждый подблок блока, длительность выравнивается по длине
|
||||||
/// сегмента HLS, а названия «из/в» берутся из групп этого канала — иначе превью показывало бы
|
/// сегмента HLS, а плейсхолдеры подставляются образцами выбранного канала — блок общий, но
|
||||||
/// случайные шоу из библиотеки.
|
/// смотреть на него надо глазами конкретного канала.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BumperPreviewTests
|
public class BumperPreviewTests
|
||||||
{
|
{
|
||||||
@@ -38,49 +37,49 @@ public class BumperPreviewTests
|
|||||||
Options.Create(new StreamingOptions { SegmentSeconds = 5 })
|
Options.Create(new StreamingOptions { SegmentSeconds = 5 })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private static BumperTemplate NewTemplate()
|
||||||
|
{
|
||||||
|
var template = BumperTemplate.Create("Блок", "Текст 1");
|
||||||
|
template
|
||||||
|
.Variants[0]
|
||||||
|
.SetLines([
|
||||||
|
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}"),
|
||||||
|
]);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<BumperRenderSpec> Specs(IBumperRenderer renderer) =>
|
||||||
|
renderer
|
||||||
|
.ReceivedCalls()
|
||||||
|
.Select(c => c.GetArguments()[1])
|
||||||
|
.OfType<BumperRenderSpec>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Render_UnknownChannel_ReturnsNotFound()
|
public async Task Render_UnknownTemplate_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
|
|
||||||
var result = await Handler(db)
|
var result = await Handler(db)
|
||||||
.Handle(
|
.Handle(new RenderBumperPreviewCommand(Guid.NewGuid(), null), CancellationToken.None);
|
||||||
new RenderBumperPreviewCommand(Guid.NewGuid(), Guid.NewGuid()),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
Assert.Equal(ChannelErrors.NotFound, result.Error);
|
Assert.Equal(BumperErrors.TemplateNotFound, result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Render_UnknownTemplate_ReturnsBumperTemplateNotFound()
|
public async Task Render_DrawsEveryVariant_WithChannelSamplesAndAlignedDuration()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Первый", "one", T0);
|
var channel = Channel.Create("Первый", "one", T0);
|
||||||
await using (var seed = fixture.New())
|
var template = NewTemplate();
|
||||||
{
|
|
||||||
seed.Channels.Add(channel);
|
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var db = fixture.New();
|
|
||||||
var result = await Handler(db)
|
|
||||||
.Handle(
|
|
||||||
new RenderBumperPreviewCommand(channel.Id, Guid.NewGuid()),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
Assert.Equal(ChannelErrors.BumperTemplateNotFound, result.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Render_DrawsEveryVariant_WithChannelShowNamesAndAlignedDuration()
|
|
||||||
{
|
|
||||||
var fixture = new TestDb();
|
|
||||||
var channel = Channel.Create("Первый", "one", T0);
|
|
||||||
var template = channel.BumperTemplates[0];
|
|
||||||
var second = template.AddVariant("Второй подблок");
|
var second = template.AddVariant("Второй подблок");
|
||||||
|
second.SetLines([
|
||||||
|
BumperLine.Create(0, BumperLineStyle.Title, BumperLineColor.Text, "{channel}"),
|
||||||
|
]);
|
||||||
|
|
||||||
// Шоу канала: слот сетки ссылается на группу, в группе — шоу.
|
// Шоу канала: слот сетки ссылается на группу, в группе — шоу.
|
||||||
var show = Show.Create("Наше шоу", ShowKind.Series);
|
var show = Show.Create("Наше шоу", ShowKind.Series);
|
||||||
@@ -105,6 +104,7 @@ public class BumperPreviewTests
|
|||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Channels.Add(channel);
|
seed.Channels.Add(channel);
|
||||||
|
seed.BumperTemplates.Add(template);
|
||||||
seed.Shows.Add(show);
|
seed.Shows.Add(show);
|
||||||
seed.Groups.Add(group);
|
seed.Groups.Add(group);
|
||||||
seed.ScheduleTemplates.Add(grid);
|
seed.ScheduleTemplates.Add(grid);
|
||||||
@@ -115,7 +115,7 @@ public class BumperPreviewTests
|
|||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await Handler(db, renderer)
|
var result = await Handler(db, renderer)
|
||||||
.Handle(
|
.Handle(
|
||||||
new RenderBumperPreviewCommand(channel.Id, template.Id),
|
new RenderBumperPreviewCommand(template.Id, channel.Id),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -136,32 +136,28 @@ public class BumperPreviewTests
|
|||||||
Arg.Any<CancellationToken>()
|
Arg.Any<CancellationToken>()
|
||||||
);
|
);
|
||||||
|
|
||||||
var specs = renderer
|
var specs = Specs(renderer);
|
||||||
.ReceivedCalls()
|
|
||||||
.Select(c => c.GetArguments()[1])
|
|
||||||
.OfType<BumperRenderSpec>()
|
|
||||||
.ToList();
|
|
||||||
// Своего звука у блока нет: 8 секунд по умолчанию, выровненные вверх до сегмента в 5 сек.
|
// Своего звука у блока нет: 8 секунд по умолчанию, выровненные вверх до сегмента в 5 сек.
|
||||||
Assert.All(specs, s => Assert.Equal(10, s.DurationSeconds));
|
Assert.All(specs, s => Assert.Equal(10, s.DurationSeconds));
|
||||||
Assert.All(specs, s => Assert.Equal("Наше шоу", s.NowTitle));
|
|
||||||
// Второго шоу у канала нет — подставляется заглушка.
|
var nowNext = specs.First(s => s.Lines.Count == 4);
|
||||||
Assert.All(specs, s => Assert.Equal("Второе шоу", s.NextTitle));
|
Assert.Equal("Наше шоу", nowNext.Lines[1].Text);
|
||||||
Assert.All(specs, s => Assert.Null(s.BackgroundFile));
|
|
||||||
Assert.All(specs, s => Assert.Null(s.PosterFile));
|
var channelLine = specs.First(s => s.Lines.Count == 1);
|
||||||
|
Assert.Equal("Первый", channelLine.Lines[0].Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Render_WithBackgroundImage_ResolvesPathFromRegistry()
|
public async Task Render_WithBackgroundImage_ResolvesPathFromRegistry()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Первый", "one", T0);
|
var template = NewTemplate();
|
||||||
var template = channel.BumperTemplates[0];
|
|
||||||
var image = Image.Create(ImageCategory.BumperBackground, ".png", "bg.png");
|
var image = Image.Create(ImageCategory.BumperBackground, ".png", "bg.png");
|
||||||
template.SetBackgroundImage(image.Id);
|
template.SetBackgroundImage(image.Id);
|
||||||
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Channels.Add(channel);
|
seed.BumperTemplates.Add(template);
|
||||||
seed.Images.Add(image);
|
seed.Images.Add(image);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
@@ -172,19 +168,40 @@ public class BumperPreviewTests
|
|||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await Handler(db, renderer, imageStore: imageStore)
|
var result = await Handler(db, renderer, imageStore: imageStore)
|
||||||
.Handle(
|
.Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None);
|
||||||
new RenderBumperPreviewCommand(channel.Id, template.Id),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
var spec = renderer
|
var spec = Specs(renderer).First();
|
||||||
.ReceivedCalls()
|
|
||||||
.Select(c => c.GetArguments()[1])
|
|
||||||
.OfType<BumperRenderSpec>()
|
|
||||||
.First();
|
|
||||||
Assert.Equal("/data/images/bg.png", spec.BackgroundFile);
|
Assert.Equal("/data/images/bg.png", spec.BackgroundFile);
|
||||||
// Шоу у канала нет — обе подписи заглушечные.
|
// Канала нет — образцы заглушечные, но кадр всё равно собирается.
|
||||||
Assert.Equal("Первое шоу", spec.NowTitle);
|
Assert.Equal("Первое шоу", spec.Lines[1].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Render_DropsLinesThatResolveToNothing()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var template = BumperTemplate.Create("Блок", "Текст 1");
|
||||||
|
template
|
||||||
|
.Variants[0]
|
||||||
|
.SetLines([
|
||||||
|
BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "{next.genre}"),
|
||||||
|
BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.BumperTemplates.Add(template);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderer = Substitute.For<IBumperRenderer>();
|
||||||
|
await using var db = fixture.New();
|
||||||
|
await Handler(db, renderer)
|
||||||
|
.Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None);
|
||||||
|
|
||||||
|
// Жанра у образца нет — строка схлопнулась, а не оставила дыру в кадре.
|
||||||
|
var spec = Specs(renderer).First();
|
||||||
|
Assert.Single(spec.Lines);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Broadcast;
|
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Tests.Support;
|
using TeleWave.Application.Tests.Support;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
@@ -9,23 +8,25 @@ namespace TeleWave.Application.Tests.Broadcast;
|
|||||||
|
|
||||||
public class BumperVariantHandlersTests
|
public class BumperVariantHandlersTests
|
||||||
{
|
{
|
||||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
private static BumperTemplate NewTemplate() => BumperTemplate.Create("Блок", "Текст 1");
|
||||||
|
|
||||||
|
private static BumperLineDto Line(string text) =>
|
||||||
|
new(BumperLineStyle.Title, BumperLineColor.Text, text);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddVariant_AutoNames_WhenBlank()
|
public async Task AddVariant_StartsFromDefaultPreset()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("c", "c", T0);
|
var template = NewTemplate();
|
||||||
var template = channel.BumperTemplates[0];
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Channels.Add(channel);
|
seed.BumperTemplates.Add(template);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new AddBumperTextVariantCommandHandler(db).Handle(
|
var result = await new AddBumperVariantCommandHandler(db).Handle(
|
||||||
new AddBumperTextVariantCommand(channel.Id, template.Id, " "),
|
new AddBumperVariantCommand(template.Id, "Текст 2"),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
@@ -33,60 +34,60 @@ public class BumperVariantHandlersTests
|
|||||||
|
|
||||||
await using var verify = fixture.New();
|
await using var verify = fixture.New();
|
||||||
var stored = await verify
|
var stored = await verify
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
.BumperTemplates.Include(t => t.Variants)
|
||||||
.ThenInclude(t => t.Variants)
|
.FirstAsync(t => t.Id == template.Id);
|
||||||
.FirstAsync(c => c.Id == channel.Id);
|
Assert.Equal(2, stored.Variants.Count);
|
||||||
var variants = stored.BumperTemplates[0].Variants;
|
var added = stored.Variants.First(v => v.Name == "Текст 2");
|
||||||
Assert.Equal(2, variants.Count);
|
// Пустой подблок в редакторе выглядит поломанным — новый начинается с «Сейчас/Далее».
|
||||||
Assert.Contains(variants, v => v.Name == "Текст 2");
|
Assert.Equal(4, added.Lines.Count);
|
||||||
|
Assert.Contains(added.Lines, l => l.Text == "{next.title}");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddVariant_UnknownTemplate_ReturnsNotFound()
|
public async Task AddVariant_UnknownTemplate_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("c", "c", T0);
|
|
||||||
await using (var seed = fixture.New())
|
|
||||||
{
|
|
||||||
seed.Channels.Add(channel);
|
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new AddBumperTextVariantCommandHandler(db).Handle(
|
|
||||||
new AddBumperTextVariantCommand(channel.Id, Guid.NewGuid(), "x"),
|
var result = await new AddBumperVariantCommandHandler(db).Handle(
|
||||||
|
new AddBumperVariantCommand(Guid.NewGuid(), "x"),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.Equal(ChannelErrors.BumperTemplateNotFound, result.Error);
|
|
||||||
|
Assert.Equal(BumperErrors.TemplateNotFound, result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateVariant_ChangesFields()
|
public async Task UpdateVariant_ReplacesLinesAndFields()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("c", "c", T0);
|
var template = NewTemplate();
|
||||||
var template = channel.BumperTemplates[0];
|
|
||||||
var variant = template.Variants[0];
|
var variant = template.Variants[0];
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Channels.Add(channel);
|
seed.BumperTemplates.Add(template);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new UpdateBumperTextVariantCommandHandler(db).Handle(
|
var result = await new UpdateBumperVariantCommandHandler(db).Handle(
|
||||||
new UpdateBumperTextVariantCommand(
|
new UpdateBumperVariantCommand(
|
||||||
channel.Id,
|
|
||||||
template.Id,
|
template.Id,
|
||||||
variant.Id,
|
variant.Id,
|
||||||
"Custom",
|
new BumperVariantInput(
|
||||||
BumperTextKind.Free,
|
"Custom",
|
||||||
"NOW",
|
BumperTrigger.Both,
|
||||||
"NEXT",
|
BumperBackground.Template,
|
||||||
"line1",
|
7,
|
||||||
"line2",
|
[
|
||||||
BumperTrigger.Both,
|
new BumperLineDto(
|
||||||
7
|
BumperLineStyle.Label,
|
||||||
|
BumperLineColor.Accent,
|
||||||
|
"ДАЛЕЕ В {next.time}"
|
||||||
|
),
|
||||||
|
Line("{next.title}"),
|
||||||
|
]
|
||||||
|
)
|
||||||
),
|
),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -95,45 +96,75 @@ public class BumperVariantHandlersTests
|
|||||||
|
|
||||||
await using var verify = fixture.New();
|
await using var verify = fixture.New();
|
||||||
var stored = await verify
|
var stored = await verify
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
.BumperTemplates.Include(t => t.Variants)
|
||||||
.ThenInclude(t => t.Variants)
|
.FirstAsync(t => t.Id == template.Id);
|
||||||
.FirstAsync(c => c.Id == channel.Id);
|
var v = stored.Variants[0];
|
||||||
var v = stored.BumperTemplates[0].Variants[0];
|
|
||||||
Assert.Equal("Custom", v.Name);
|
Assert.Equal("Custom", v.Name);
|
||||||
Assert.Equal(BumperTextKind.Free, v.Kind);
|
|
||||||
Assert.Equal(7, v.Weight);
|
|
||||||
Assert.Equal(BumperTrigger.Both, v.Trigger);
|
Assert.Equal(BumperTrigger.Both, v.Trigger);
|
||||||
|
Assert.Equal(BumperBackground.Template, v.Background);
|
||||||
|
Assert.Equal(7, v.Weight);
|
||||||
|
Assert.Equal(2, v.Lines.Count);
|
||||||
|
Assert.Equal("ДАЛЕЕ В {next.time}", v.Lines[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateVariant_UnknownPlaceholder_IsRejected()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var template = NewTemplate();
|
||||||
|
var variant = template.Variants[0];
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.BumperTemplates.Add(template);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var result = await new UpdateBumperVariantCommandHandler(db).Handle(
|
||||||
|
new UpdateBumperVariantCommand(
|
||||||
|
template.Id,
|
||||||
|
variant.Id,
|
||||||
|
new BumperVariantInput(
|
||||||
|
"Custom",
|
||||||
|
BumperTrigger.Both,
|
||||||
|
BumperBackground.Template,
|
||||||
|
1,
|
||||||
|
[Line("Далее {next.tittle}")]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
// В эфире опечатка превратилась бы в пустоту, и заметить это было бы уже некому.
|
||||||
|
Assert.Equal("Bumpers.UnknownPlaceholders", result.Error.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RemoveVariant_CannotRemoveLast_ButRemovesExtra()
|
public async Task RemoveVariant_CannotRemoveLast_ButRemovesExtra()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("c", "c", T0);
|
var template = NewTemplate();
|
||||||
var template = channel.BumperTemplates[0];
|
|
||||||
var only = template.Variants[0];
|
var only = template.Variants[0];
|
||||||
var extra = template.AddVariant("Text 2");
|
var extra = template.AddVariant("Текст 2");
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Channels.Add(channel);
|
seed.BumperTemplates.Add(template);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var handler = new RemoveBumperTextVariantCommandHandler(db);
|
var okRemove = await new RemoveBumperVariantCommandHandler(db).Handle(
|
||||||
|
new RemoveBumperVariantCommand(template.Id, extra.Id),
|
||||||
var okRemove = await handler.Handle(
|
|
||||||
new RemoveBumperTextVariantCommand(channel.Id, template.Id, extra.Id),
|
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(okRemove.IsSuccess);
|
Assert.True(okRemove.IsSuccess);
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
await using var db2 = fixture.New();
|
await using var db2 = fixture.New();
|
||||||
var lastGuard = await new RemoveBumperTextVariantCommandHandler(db2).Handle(
|
var lastGuard = await new RemoveBumperVariantCommandHandler(db2).Handle(
|
||||||
new RemoveBumperTextVariantCommand(channel.Id, template.Id, only.Id),
|
new RemoveBumperVariantCommand(template.Id, only.Id),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.Equal(ChannelErrors.CannotRemoveLastBumperTextVariant, lastGuard.Error);
|
Assert.Equal(BumperErrors.CannotRemoveLastVariant, lastGuard.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,26 +7,23 @@ using Xunit;
|
|||||||
namespace TeleWave.Application.Tests.Broadcast;
|
namespace TeleWave.Application.Tests.Broadcast;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Карточка канала: собственные свойства канала плюс блоки заставки с подблоками. Сетка сюда не
|
/// Карточка канала: только собственные свойства канала. Сетка, стыки и заставки сюда не входят —
|
||||||
/// входит — она запрашивается отдельно.
|
/// они общие и запрашиваются отдельно.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ChannelDetailsTests
|
public class ChannelDetailsTests
|
||||||
{
|
{
|
||||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetChannel_MapsSettingsAndBumperTemplates()
|
public async Task GetChannel_MapsOwnSettings()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Первый", "one", T0);
|
var channel = Channel.Create("Первый", "one", T0);
|
||||||
var fillerId = Guid.NewGuid();
|
var fillerId = Guid.NewGuid();
|
||||||
var logoId = Guid.NewGuid();
|
var logoId = Guid.NewGuid();
|
||||||
channel.UpdateSettings("Первый", isEnabled: true, bumpersEnabled: true, fillerId);
|
channel.UpdateSettings("Первый", isEnabled: true, fillerId);
|
||||||
channel.UpdateTimeSettings(3, 120, new TimeOnly(5, 0));
|
channel.UpdateTimeSettings(3, 120, new TimeOnly(5, 0));
|
||||||
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.Random);
|
|
||||||
channel.UpdateViewerSettings(logoId, LogoCorner.BottomLeft, 0.4, showClock: true, 0.25);
|
channel.UpdateViewerSettings(logoId, LogoCorner.BottomLeft, 0.4, showClock: true, 0.25);
|
||||||
// Второй блок должен приехать после дефолтного — порядок задаёт позиция.
|
|
||||||
var extra = channel.AddBumperTemplate("Ночной");
|
|
||||||
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
@@ -48,22 +45,11 @@ public class ChannelDetailsTests
|
|||||||
Assert.Equal(120, dto.UtcOffsetMinutes);
|
Assert.Equal(120, dto.UtcOffsetMinutes);
|
||||||
Assert.Equal(new TimeOnly(5, 0), dto.DayStartTime);
|
Assert.Equal(new TimeOnly(5, 0), dto.DayStartTime);
|
||||||
Assert.Equal(fillerId, dto.FillerAssetId);
|
Assert.Equal(fillerId, dto.FillerAssetId);
|
||||||
Assert.True(dto.BumpersEnabled);
|
|
||||||
Assert.Equal(BumperFont.Serif, dto.Bumper.Font);
|
|
||||||
Assert.Equal(BumperSelection.Random, dto.Bumper.Selection);
|
|
||||||
Assert.Equal(logoId, dto.Viewer.LogoImageId);
|
Assert.Equal(logoId, dto.Viewer.LogoImageId);
|
||||||
Assert.Equal(LogoCorner.BottomLeft, dto.Viewer.LogoCorner);
|
Assert.Equal(LogoCorner.BottomLeft, dto.Viewer.LogoCorner);
|
||||||
Assert.Equal(0.4, dto.Viewer.LogoOpacity);
|
Assert.Equal(0.4, dto.Viewer.LogoOpacity);
|
||||||
Assert.True(dto.Viewer.ShowClock);
|
Assert.True(dto.Viewer.ShowClock);
|
||||||
Assert.Equal(0.25, dto.Viewer.AnalogFilterStrength);
|
Assert.Equal(0.25, dto.Viewer.AnalogFilterStrength);
|
||||||
|
|
||||||
Assert.Equal(2, dto.BumperTemplates.Count);
|
|
||||||
Assert.True(dto.BumperTemplates[0].IsDefault);
|
|
||||||
Assert.Equal(extra.Id, dto.BumperTemplates[1].Id);
|
|
||||||
Assert.Equal("Ночной", dto.BumperTemplates[1].Name);
|
|
||||||
// Своего звука у свежего блока нет — джингл синтезируется.
|
|
||||||
Assert.False(dto.BumperTemplates[1].HasAudio);
|
|
||||||
Assert.NotEmpty(dto.BumperTemplates[0].Variants);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -48,14 +48,7 @@ public class ChannelHandlersTests
|
|||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
|
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
|
||||||
new UpdateChannelSettingsCommand(
|
new UpdateChannelSettingsCommand(channel.Id, "c2", true, null),
|
||||||
channel.Id,
|
|
||||||
"c",
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
|
|
||||||
null
|
|
||||||
),
|
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
@@ -63,7 +56,7 @@ public class ChannelHandlersTests
|
|||||||
|
|
||||||
await using var verify = fixture.New();
|
await using var verify = fixture.New();
|
||||||
var stored = await verify.Channels.FindAsync(channel.Id);
|
var stored = await verify.Channels.FindAsync(channel.Id);
|
||||||
Assert.Equal(BumperFont.Sans, stored!.BumperFont);
|
Assert.Equal("c2", stored!.Name);
|
||||||
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
|
Assert.True(stored.IsEnabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,8 +61,19 @@ public class QueryHandlersTests
|
|||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("c", "c", T0);
|
var channel = Channel.Create("c", "c", T0);
|
||||||
var show = Show.Create("Show A", ShowKind.Series);
|
var show = Show.Create("Show A", ShowKind.Series);
|
||||||
var variant = channel.BumperTemplates[0].Variants[0];
|
var bumperTemplate = BumperTemplate.Create("Блок", "Текст 1");
|
||||||
|
var variant = bumperTemplate.Variants[0];
|
||||||
var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
var bumperAsset = MediaAsset.RegisterGenerated("Блок: СЕЙЧАС / Show A");
|
||||||
|
// Метка в расписании берётся из кэша заставки — там лежит ровно тот текст, что играл.
|
||||||
|
var cache = BumperAsset.Create(
|
||||||
|
bumperTemplate.Id,
|
||||||
|
variant.Id,
|
||||||
|
"sig",
|
||||||
|
"""[{"style":"Label","color":"Accent","text":"СЕЙЧАС"},{"style":"Title","color":"Text","text":"Show A"}]""",
|
||||||
|
null,
|
||||||
|
bumperAsset.Id
|
||||||
|
);
|
||||||
var program = ScheduleEntry.Program(
|
var program = ScheduleEntry.Program(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
asset.Id,
|
asset.Id,
|
||||||
@@ -73,7 +84,7 @@ public class QueryHandlersTests
|
|||||||
);
|
);
|
||||||
var bumper = ScheduleEntry.Bumper(
|
var bumper = ScheduleEntry.Bumper(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
Guid.NewGuid(),
|
bumperAsset.Id,
|
||||||
T0.AddMinutes(20),
|
T0.AddMinutes(20),
|
||||||
T0.AddMinutes(20).AddSeconds(8),
|
T0.AddMinutes(20).AddSeconds(8),
|
||||||
show.Id,
|
show.Id,
|
||||||
@@ -83,7 +94,10 @@ public class QueryHandlersTests
|
|||||||
{
|
{
|
||||||
seed.Shows.Add(show);
|
seed.Shows.Add(show);
|
||||||
seed.Channels.Add(channel);
|
seed.Channels.Add(channel);
|
||||||
|
seed.BumperTemplates.Add(bumperTemplate);
|
||||||
seed.MediaAssets.Add(asset);
|
seed.MediaAssets.Add(asset);
|
||||||
|
seed.MediaAssets.Add(bumperAsset);
|
||||||
|
seed.BumperAssets.Add(cache);
|
||||||
seed.ScheduleEntries.Add(program);
|
seed.ScheduleEntries.Add(program);
|
||||||
seed.ScheduleEntries.Add(bumper);
|
seed.ScheduleEntries.Add(bumper);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
@@ -98,7 +112,8 @@ public class QueryHandlersTests
|
|||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal(2, result.Value.Count);
|
Assert.Equal(2, result.Value.Count);
|
||||||
var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper);
|
var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper);
|
||||||
Assert.NotNull(bumperDto.BumperName);
|
Assert.Equal("Текст 1", bumperDto.BumperName);
|
||||||
|
Assert.Equal("СЕЙЧАС · Show A", bumperDto.BumperText);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -150,52 +165,4 @@ public class QueryHandlersTests
|
|||||||
);
|
);
|
||||||
Assert.True(ok.IsSuccess);
|
Assert.True(ok.IsSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AddAndRemoveBumperTemplate_WorkThroughStorage()
|
|
||||||
{
|
|
||||||
var fixture = new TestDb();
|
|
||||||
var channel = Channel.Create("c", "c", T0);
|
|
||||||
await using (var seed = fixture.New())
|
|
||||||
{
|
|
||||||
seed.Channels.Add(channel);
|
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid templateId;
|
|
||||||
await using (var db = fixture.New())
|
|
||||||
{
|
|
||||||
var added = await new AddBumperTemplateCommandHandler(db).Handle(
|
|
||||||
new AddBumperTemplateCommand(channel.Id, ""),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
Assert.True(added.IsSuccess);
|
|
||||||
templateId = added.Value;
|
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
var storage = Substitute.For<IBumperTemplateStorage>();
|
|
||||||
await using (var db = fixture.New())
|
|
||||||
{
|
|
||||||
var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
|
||||||
new RemoveBumperTemplateCommand(channel.Id, templateId),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
Assert.True(removed.IsSuccess);
|
|
||||||
}
|
|
||||||
await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>());
|
|
||||||
|
|
||||||
// дефолтный блок удалить нельзя
|
|
||||||
await using (var db = fixture.New())
|
|
||||||
{
|
|
||||||
var def = await db
|
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
|
||||||
.FirstAsync(c => c.Id == channel.Id);
|
|
||||||
var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
|
||||||
new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ public class DeleteGuardsTests
|
|||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var asset = MediaAsset.Register("filler.mkv", ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register("filler.mkv", ".mkv", MediaSource.Upload);
|
||||||
var channel = Channel.Create("c", "c", T0);
|
var channel = Channel.Create("c", "c", T0);
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, asset.Id);
|
channel.UpdateSettings(channel.Name, isEnabled: true, asset.Id);
|
||||||
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ public class EntryTraceTests
|
|||||||
var show = Show.Create("Фильм", ShowKind.Single);
|
var show = Show.Create("Фильм", ShowKind.Single);
|
||||||
var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register("film.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
|
||||||
var junction = JunctionTemplate.Create(channel.Id, "Прайм");
|
var junction = JunctionTemplate.Create("Прайм");
|
||||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||||
var layer = template.AddLayer("Прайм", 10);
|
var layer = template.AddLayer("Прайм", 10);
|
||||||
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ public class ListPublicChannelsTests
|
|||||||
var disabled = Channel.Create("Выключенный", "off", T0);
|
var disabled = Channel.Create("Выключенный", "off", T0);
|
||||||
|
|
||||||
foreach (var channel in new[] { numbered, first, unnumbered })
|
foreach (var channel in new[] { numbered, first, unnumbered })
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, null);
|
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
||||||
disabled.UpdateSettings("Выключенный", isEnabled: false, bumpersEnabled: false, null);
|
disabled.UpdateSettings("Выключенный", isEnabled: false, null);
|
||||||
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
@@ -57,7 +57,7 @@ public class ListPublicChannelsTests
|
|||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Первый", "one", T0);
|
var channel = Channel.Create("Первый", "one", T0);
|
||||||
channel.UpdateSettings("Первый", isEnabled: true, bumpersEnabled: false, null);
|
channel.UpdateSettings("Первый", isEnabled: true, null);
|
||||||
var logoId = Guid.NewGuid();
|
var logoId = Guid.NewGuid();
|
||||||
channel.UpdateViewerSettings(logoId, LogoCorner.TopRight, 0.5, showClock: true, 0.3);
|
channel.UpdateViewerSettings(logoId, LogoCorner.TopRight, 0.5, showClock: true, 0.3);
|
||||||
|
|
||||||
|
|||||||
@@ -11,44 +11,57 @@ namespace TeleWave.Application.Tests.Validators;
|
|||||||
public class ValidatorTests
|
public class ValidatorTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateBumperTextVariant_ChecksLengthsAndWeight()
|
public void UpdateBumperVariant_ChecksNameWeightAndLines()
|
||||||
{
|
{
|
||||||
var v = new UpdateBumperTextVariantCommandValidator();
|
var v = new UpdateBumperVariantCommandValidator();
|
||||||
|
|
||||||
var good = new UpdateBumperTextVariantCommand(
|
var good = new UpdateBumperVariantCommand(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
Guid.NewGuid(),
|
new BumperVariantInput(
|
||||||
"Name",
|
"Name",
|
||||||
BumperTextKind.NowNext,
|
BumperTrigger.Both,
|
||||||
"NOW",
|
BumperBackground.NextPoster,
|
||||||
"NEXT",
|
3,
|
||||||
"l1",
|
[new BumperLineDto(BumperLineStyle.Title, BumperLineColor.Text, "{next.title}")]
|
||||||
"l2",
|
)
|
||||||
BumperTrigger.Both,
|
|
||||||
3
|
|
||||||
);
|
);
|
||||||
Assert.True(v.Validate(good).IsValid);
|
Assert.True(v.Validate(good).IsValid);
|
||||||
|
|
||||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
Assert.False(v.Validate(Patch(good, input => input with { Name = "" })).IsValid);
|
||||||
Assert.False(v.Validate(good with { Weight = -1 }).IsValid);
|
Assert.False(v.Validate(Patch(good, input => input with { Weight = -1 })).IsValid);
|
||||||
Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid);
|
Assert.False(
|
||||||
|
v.Validate(
|
||||||
|
Patch(
|
||||||
|
good,
|
||||||
|
input =>
|
||||||
|
input with
|
||||||
|
{
|
||||||
|
Lines =
|
||||||
|
[
|
||||||
|
new BumperLineDto(
|
||||||
|
BumperLineStyle.Title,
|
||||||
|
BumperLineColor.Text,
|
||||||
|
new string('x', 121)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).IsValid
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static UpdateBumperVariantCommand Patch(
|
||||||
|
UpdateBumperVariantCommand command,
|
||||||
|
Func<BumperVariantInput, BumperVariantInput> change
|
||||||
|
) => command with { Input = change(command.Input) };
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateChannelSettings_ChecksRanges()
|
public void UpdateChannelSettings_ChecksRanges()
|
||||||
{
|
{
|
||||||
var v = new UpdateChannelSettingsCommandValidator();
|
var v = new UpdateChannelSettingsCommandValidator();
|
||||||
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
|
|
||||||
|
|
||||||
var good = new UpdateChannelSettingsCommand(
|
var good = new UpdateChannelSettingsCommand(Guid.NewGuid(), "Name", true, null);
|
||||||
Guid.NewGuid(),
|
|
||||||
"Name",
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
bumper,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
Assert.True(v.Validate(good).IsValid);
|
Assert.True(v.Validate(good).IsValid);
|
||||||
|
|
||||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ namespace TeleWave.Domain.Tests.Broadcast;
|
|||||||
|
|
||||||
public class BumperTemplateTests
|
public class BumperTemplateTests
|
||||||
{
|
{
|
||||||
private static BumperTemplate NewTemplate() =>
|
private static BumperTemplate NewTemplate() => BumperTemplate.Create("Block", "Текст 1");
|
||||||
Channel.Create("c", "c", DateTimeOffset.UnixEpoch).AddBumperTemplate("Block");
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Create_HasDefaultsAndOneVariant()
|
public void Create_HasDefaultsAndOneVariant()
|
||||||
@@ -53,12 +52,17 @@ public class BumperTemplateTests
|
|||||||
{
|
{
|
||||||
var t = NewTemplate();
|
var t = NewTemplate();
|
||||||
|
|
||||||
t.UpdateStyle("New", "0x111111", "0x222222", "0x333333", "black");
|
t.UpdateStyle(
|
||||||
|
new BumperStyle("New", BumperFont.Serif, "0x111111", "0x222222", "0x333333", "black")
|
||||||
|
);
|
||||||
|
|
||||||
Assert.Equal("New", t.Name);
|
Assert.Equal("New", t.Name);
|
||||||
|
Assert.Equal(BumperFont.Serif, t.Font);
|
||||||
Assert.Equal("0x111111", t.BackgroundColor);
|
Assert.Equal("0x111111", t.BackgroundColor);
|
||||||
Assert.Equal("0x333333", t.AccentColor);
|
Assert.Equal("0x333333", t.AccentColor);
|
||||||
Assert.Equal("black", t.TextColor);
|
Assert.Equal("black", t.TextColor);
|
||||||
|
// Оформление входит в сигнатуру рендера — правка обязана пересобрать заставки.
|
||||||
|
Assert.Equal(1, t.Revision);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user