Refactor Channel endpoints and data models: replace jingle functionality with bumper templates, update related commands and handlers, and enhance API routes for managing bumper templates. Remove obsolete jingle-related code and adjust channel data structures to support new bumper template features.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.AddChannelAd;
|
||||
using TeleWave.Application.Broadcast.AddChannelJingle;
|
||||
using TeleWave.Application.Broadcast.AddChannelShow;
|
||||
using TeleWave.Application.Broadcast.BumperBackground;
|
||||
using TeleWave.Application.Broadcast.BumperMusic;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.CreateChannel;
|
||||
using TeleWave.Application.Broadcast.CreateOverride;
|
||||
using TeleWave.Application.Broadcast.DeleteOverride;
|
||||
@@ -14,18 +14,23 @@ using TeleWave.Application.Broadcast.GetSchedule;
|
||||
using TeleWave.Application.Broadcast.ListChannels;
|
||||
using TeleWave.Application.Broadcast.RegenerateSchedule;
|
||||
using TeleWave.Application.Broadcast.RemoveChannelAd;
|
||||
using TeleWave.Application.Broadcast.RemoveChannelJingle;
|
||||
using TeleWave.Application.Broadcast.RemoveChannelShow;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelShow;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
using TeleWave.Infrastructure.Media;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
public static class ChannelEndpoints
|
||||
{
|
||||
private static readonly Regex BumperSegmentFileName = new(
|
||||
@"^seg\d{1,6}\.ts$",
|
||||
RegexOptions.Compiled
|
||||
);
|
||||
|
||||
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/channels")
|
||||
@@ -55,24 +60,38 @@ public static class ChannelEndpoints
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapPost("/{id:guid}/jingles", AddJingle)
|
||||
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin
|
||||
.MapDelete("/{id:guid}/jingles/{channelJingleId:guid}", RemoveJingle)
|
||||
.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", UploadTemplateBackground)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/background", ClearTemplateBackground)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapPut("/{id:guid}/bumper/background", UploadBackground)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/{id:guid}/bumper/background", ClearBackground)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPut("/{id:guid}/bumper/music", UploadMusic)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/{id:guid}/bumper/music", ClearMusic)
|
||||
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapGet(
|
||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/index.m3u8",
|
||||
PreviewPlaylist
|
||||
);
|
||||
admin.MapGet(
|
||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{file}",
|
||||
PreviewSegment
|
||||
);
|
||||
|
||||
admin
|
||||
.MapPost("/{id:guid}/overrides", CreateOverride)
|
||||
@@ -216,15 +235,15 @@ public static class ChannelEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddJingle(
|
||||
private static async Task<IResult> AddBumperTemplate(
|
||||
Guid id,
|
||||
AddChannelJingleBody body,
|
||||
AddBumperTemplateBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddChannelJingleCommand(id, body.MediaAssetId),
|
||||
new AddBumperTemplateCommand(id, body.Name),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
@@ -232,22 +251,91 @@ public static class ChannelEndpoints
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveJingle(
|
||||
private static async Task<IResult> UpdateBumperTemplate(
|
||||
Guid id,
|
||||
Guid channelJingleId,
|
||||
Guid templateId,
|
||||
UpdateBumperTemplateBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveChannelJingleCommand(id, channelJingleId),
|
||||
new UpdateBumperTemplateCommand(
|
||||
id,
|
||||
templateId,
|
||||
body.Name,
|
||||
body.BackgroundColor,
|
||||
body.BackgroundColor2,
|
||||
body.AccentColor,
|
||||
body.TextColor
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadBackground(
|
||||
private static async Task<IResult> RemoveBumperTemplate(
|
||||
Guid id,
|
||||
Guid templateId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveBumperTemplateCommand(id, templateId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadTemplateAudio(
|
||||
Guid id,
|
||||
Guid templateId,
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IBumperTemplateStorage storage,
|
||||
IAudioProbe probe,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext)
|
||||
return ChannelErrors.InvalidBumperFile.ToProblem();
|
||||
|
||||
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
|
||||
|
||||
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
||||
var path = storage.AudioPath(templateId, ext);
|
||||
var duration = path is null
|
||||
? null
|
||||
: await probe.TryGetDurationAsync(path, cancellationToken);
|
||||
|
||||
var result = await sender.Send(
|
||||
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0),
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
storage.DeleteAudio(templateId);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClearTemplateAudio(
|
||||
Guid id,
|
||||
Guid templateId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ClearBumperTemplateAudioCommand(id, templateId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadTemplateBackground(
|
||||
Guid id,
|
||||
Guid templateId,
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IBumperTemplateStorage storage,
|
||||
@@ -258,52 +346,96 @@ public static class ChannelEndpoints
|
||||
if (ResolveBumperExtension(fileName, request, BumperFiles.BackgroundExtensions) is not { } ext)
|
||||
return ChannelErrors.InvalidBumperFile.ToProblem();
|
||||
|
||||
await storage.SaveBackgroundAsync(id, ext, request.Body, cancellationToken);
|
||||
await storage.SaveBackgroundAsync(templateId, ext, request.Body, cancellationToken);
|
||||
|
||||
var result = await sender.Send(new SetBumperBackgroundCommand(id, ext), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new SetBumperTemplateBackgroundCommand(id, templateId, ext),
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
storage.DeleteBackground(id);
|
||||
storage.DeleteBackground(templateId);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClearBackground(
|
||||
private static async Task<IResult> ClearTemplateBackground(
|
||||
Guid id,
|
||||
Guid templateId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ClearBumperBackgroundCommand(id), cancellationToken);
|
||||
var result = await sender.Send(
|
||||
new ClearBumperTemplateBackgroundCommand(id, templateId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadMusic(
|
||||
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
||||
private static async Task<IResult> RenderPreview(
|
||||
Guid id,
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IBumperTemplateStorage storage,
|
||||
Guid templateId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (ResolveBumperExtension(fileName, request, BumperFiles.MusicExtensions) is not { } ext)
|
||||
return ChannelErrors.InvalidBumperFile.ToProblem();
|
||||
|
||||
await storage.SaveMusicAsync(id, ext, request.Body, cancellationToken);
|
||||
|
||||
var result = await sender.Send(new SetBumperMusicCommand(id, ext), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
storage.DeleteMusic(id);
|
||||
return result.ToHttpResult();
|
||||
var result = await sender.Send(
|
||||
new RenderBumperPreviewQuery(id, templateId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClearMusic(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
/// <summary>Плейлист превью: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||
private static IResult PreviewPlaylist(Guid id, Guid templateId, MediaPathResolver paths)
|
||||
{
|
||||
var result = await sender.Send(new ClearBumperMusicCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
var previewId = BumperPreview.AssetId(templateId);
|
||||
string indexPath;
|
||||
try
|
||||
{
|
||||
indexPath = paths.SegmentPath(previewId, "index.m3u8");
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
if (!File.Exists(indexPath))
|
||||
return Results.NotFound();
|
||||
|
||||
var baseUrl = $"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/";
|
||||
var sb = new StringBuilder();
|
||||
foreach (var line in File.ReadLines(indexPath))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
continue;
|
||||
// Комментарии/директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
|
||||
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
|
||||
.Append('\n');
|
||||
}
|
||||
|
||||
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
private static IResult PreviewSegment(Guid id, Guid templateId, string file, MediaPathResolver paths)
|
||||
{
|
||||
if (!BumperSegmentFileName.IsMatch(file))
|
||||
return Results.NotFound();
|
||||
|
||||
var previewId = BumperPreview.AssetId(templateId);
|
||||
string path;
|
||||
try
|
||||
{
|
||||
path = paths.SegmentPath(previewId, file);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
if (!File.Exists(path))
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
|
||||
@@ -405,13 +537,22 @@ public sealed record UpdateChannelShowBody(
|
||||
|
||||
public sealed record AddChannelAdBody(Guid MediaAssetId);
|
||||
|
||||
public sealed record AddChannelJingleBody(Guid MediaAssetId);
|
||||
public sealed record AddBumperTemplateBody(string Name);
|
||||
|
||||
/// <summary>Ограничения на загружаемые файлы заставки (фон/музыка).</summary>
|
||||
public sealed record UpdateBumperTemplateBody(
|
||||
string Name,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor
|
||||
);
|
||||
|
||||
/// <summary>Ограничения на загружаемые файлы блока заставки (звук/фон-картинка).</summary>
|
||||
internal static class BumperFiles
|
||||
{
|
||||
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
|
||||
|
||||
// Фон блока — только картинка (видео-фоны в новой модели не поддерживаются).
|
||||
public static readonly IReadOnlySet<string> BackgroundExtensions = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
@@ -422,13 +563,9 @@ internal static class BumperFiles
|
||||
".webp",
|
||||
".bmp",
|
||||
".gif",
|
||||
".mp4",
|
||||
".mov",
|
||||
".mkv",
|
||||
".webm",
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> MusicExtensions = new HashSet<string>(
|
||||
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
{
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelJingle;
|
||||
|
||||
public sealed record AddChannelJingleCommand(Guid ChannelId, Guid MediaAssetId)
|
||||
: ICommand<Result<Guid>>;
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelJingle;
|
||||
|
||||
public sealed class AddChannelJingleCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddChannelJingleCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddChannelJingleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels
|
||||
.Include(c => c.Jingles)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var assetExists = await dbContext.MediaAssets.AnyAsync(
|
||||
a => a.Id == command.MediaAssetId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!assetExists)
|
||||
return Result.Failure<Guid>(ChannelErrors.AssetNotFound);
|
||||
|
||||
if (channel.HasJingle(command.MediaAssetId))
|
||||
return Result.Failure<Guid>(ChannelErrors.JingleAlreadyAdded);
|
||||
|
||||
var jingle = channel.AddJingle(command.MediaAssetId);
|
||||
return Result.Success(jingle.Id);
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperBackground;
|
||||
|
||||
public sealed record ClearBumperBackgroundCommand(Guid ChannelId) : ICommand<Result>;
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperBackground;
|
||||
|
||||
public sealed class ClearBumperBackgroundCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperTemplateStorage storage
|
||||
) : ICommandHandler<ClearBumperBackgroundCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ClearBumperBackgroundCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
||||
c => c.Id == command.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
channel.ClearBumperBackground();
|
||||
storage.DeleteBackground(channel.Id);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperBackground;
|
||||
|
||||
/// <summary>Отметить, что для канала загружен фон заставки (файл уже сохранён хранилищем).</summary>
|
||||
public sealed record SetBumperBackgroundCommand(Guid ChannelId, string Extension) : ICommand<Result>;
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperBackground;
|
||||
|
||||
public sealed class SetBumperBackgroundCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<SetBumperBackgroundCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
SetBumperBackgroundCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
||||
c => c.Id == command.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
channel.SetBumperBackground(command.Extension);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperMusic;
|
||||
|
||||
public sealed record ClearBumperMusicCommand(Guid ChannelId) : ICommand<Result>;
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperMusic;
|
||||
|
||||
public sealed class ClearBumperMusicCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperTemplateStorage storage
|
||||
) : ICommandHandler<ClearBumperMusicCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ClearBumperMusicCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
||||
c => c.Id == command.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
channel.ClearBumperMusic();
|
||||
storage.DeleteMusic(channel.Id);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperMusic;
|
||||
|
||||
/// <summary>Отметить, что для канала загружена музыка заставки (файл уже сохранён хранилищем).</summary>
|
||||
public sealed record SetBumperMusicCommand(Guid ChannelId, string Extension) : ICommand<Result>;
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperMusic;
|
||||
|
||||
public sealed class SetBumperMusicCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<SetBumperMusicCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
SetBumperMusicCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
||||
c => c.Id == command.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
channel.SetBumperMusic(command.Extension);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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>>;
|
||||
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер,
|
||||
/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые.
|
||||
/// </summary>
|
||||
public static class BumperPreview
|
||||
{
|
||||
public static Guid AssetId(Guid templateId) => new(MD5.HashData(templateId.ToByteArray()));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Удалить загруженный звук блока (вернуться к синтезированному джинглу).</summary>
|
||||
public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId)
|
||||
: ICommand<Result>;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class ClearBumperTemplateAudioCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperTemplateStorage storage
|
||||
) : ICommandHandler<ClearBumperTemplateAudioCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ClearBumperTemplateAudioCommand 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);
|
||||
|
||||
template.ClearAudio();
|
||||
storage.DeleteAudio(command.TemplateId);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Удалить загруженную фон-картинку блока (вернуться к градиенту/постеру).</summary>
|
||||
public sealed record ClearBumperTemplateBackgroundCommand(Guid ChannelId, Guid TemplateId)
|
||||
: ICommand<Result>;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class ClearBumperTemplateBackgroundCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperTemplateStorage storage
|
||||
) : ICommandHandler<ClearBumperTemplateBackgroundCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ClearBumperTemplateBackgroundCommand 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);
|
||||
|
||||
template.ClearBackgroundImage();
|
||||
storage.DeleteBackground(command.TemplateId);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Удалить блок заставки (кроме дефолтного) и его файлы.</summary>
|
||||
public sealed record RemoveBumperTemplateCommand(Guid ChannelId, Guid TemplateId) : ICommand<Result>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
storage.DeleteTemplate(command.TemplateId);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Синхронно рендерит пример заставки блока (с примерными названиями шоу) и возвращает id
|
||||
/// ассета-превью. БД не меняет — это read-side генерация артефакта для предпросмотра.
|
||||
/// </summary>
|
||||
public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId)
|
||||
: IQuery<Result<Guid>>;
|
||||
@@ -0,0 +1,90 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class RenderBumperPreviewQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperRenderer renderer,
|
||||
IBumperTemplateStorage storage,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
) : IQueryHandler<RenderBumperPreviewQuery, Result<Guid>>
|
||||
{
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
|
||||
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
|
||||
private const int DefaultBumperDurationSeconds = 8;
|
||||
|
||||
public async Task<Result<Guid>> Handle(
|
||||
RenderBumperPreviewQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels.AsNoTracking()
|
||||
.Include(c => c.BumperTemplates)
|
||||
.Include(c => c.Shows)
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
|
||||
if (template is null)
|
||||
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
|
||||
|
||||
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
||||
|
||||
var seconds =
|
||||
template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds;
|
||||
var aligned = (int)(
|
||||
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
||||
);
|
||||
|
||||
var spec = new BumperRenderSpec(
|
||||
aligned,
|
||||
_bumper.Width,
|
||||
_bumper.Height,
|
||||
template.BackgroundColor,
|
||||
template.BackgroundColor2,
|
||||
template.AccentColor,
|
||||
template.TextColor,
|
||||
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans,
|
||||
channel.BumperNowLabel,
|
||||
fromName,
|
||||
channel.BumperNextLabel,
|
||||
toName,
|
||||
storage.BackgroundPath(template.Id, template.BackgroundImageExtension),
|
||||
storage.AudioPath(template.Id, template.AudioExtension),
|
||||
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
|
||||
null
|
||||
);
|
||||
|
||||
var previewId = BumperPreview.AssetId(template.Id);
|
||||
await renderer.RenderAsync(previewId, spec, cancellationToken);
|
||||
return Result.Success(previewId);
|
||||
}
|
||||
|
||||
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary>
|
||||
private async Task<(string From, string To)> SampleNamesAsync(
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().Take(2).ToList();
|
||||
var names = showIds.Count == 0
|
||||
? []
|
||||
: await dbContext.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => s.Name)
|
||||
.Take(2)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (names.ElementAtOrDefault(0) ?? "Первое шоу", names.ElementAtOrDefault(1) ?? "Второе шоу");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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>;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class SetBumperTemplateAudioCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<SetBumperTemplateAudioCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
SetBumperTemplateAudioCommand 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);
|
||||
|
||||
template.SetAudio(command.Extension, command.DurationSeconds);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Отметить загруженную фон-картинку блока (расширение — с точкой).</summary>
|
||||
public sealed record SetBumperTemplateBackgroundCommand(
|
||||
Guid ChannelId,
|
||||
Guid TemplateId,
|
||||
string Extension
|
||||
) : ICommand<Result>;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<SetBumperTemplateBackgroundCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
SetBumperTemplateBackgroundCommand 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);
|
||||
|
||||
template.SetBackgroundImage(command.Extension);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class UpdateBumperTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateBumperTemplateCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateBumperTemplateCommand 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);
|
||||
|
||||
template.UpdateStyle(
|
||||
command.Name.Trim(),
|
||||
command.BackgroundColor,
|
||||
command.BackgroundColor2,
|
||||
command.AccentColor,
|
||||
command.TextColor
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
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();
|
||||
}
|
||||
@@ -17,8 +17,6 @@ public sealed record ChannelShowDto(
|
||||
|
||||
public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
|
||||
|
||||
public sealed record ChannelJingleDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
|
||||
|
||||
public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight);
|
||||
|
||||
public sealed record ProgrammingOverrideDto(
|
||||
@@ -29,20 +27,29 @@ public sealed record ProgrammingOverrideDto(
|
||||
IReadOnlyList<OverrideShowDto> Shows
|
||||
);
|
||||
|
||||
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. <see cref="BumperTemplateDto"/>).</summary>
|
||||
public sealed record BumperSettingsDto(
|
||||
BumperMode Mode,
|
||||
int DurationSeconds,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
BumperFont Font,
|
||||
string NowLabel,
|
||||
string NextLabel,
|
||||
int MinIntervalMinutes,
|
||||
bool OnlyBetweenDifferentShows,
|
||||
BumperSelection Selection
|
||||
);
|
||||
|
||||
/// <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,
|
||||
bool HasBackground,
|
||||
bool HasMusic
|
||||
bool HasAudio,
|
||||
double? AudioDurationSeconds
|
||||
);
|
||||
|
||||
public sealed record ChannelDto(
|
||||
@@ -54,9 +61,9 @@ public sealed record ChannelDto(
|
||||
int AdsPerBreak,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsDto Bumper,
|
||||
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
||||
Guid? FillerAssetId,
|
||||
IReadOnlyList<ChannelShowDto> Shows,
|
||||
IReadOnlyList<ChannelAdDto> Ads,
|
||||
IReadOnlyList<ChannelJingleDto> Jingles,
|
||||
IReadOnlyList<ProgrammingOverrideDto> Overrides
|
||||
);
|
||||
|
||||
@@ -36,14 +36,14 @@ public static class ChannelErrors
|
||||
"Реклама не найдена в пуле канала."
|
||||
);
|
||||
|
||||
public static readonly Error JingleAlreadyAdded = Error.Conflict(
|
||||
"Channels.JingleAlreadyAdded",
|
||||
"Этот ролик уже в пуле джинглов канала."
|
||||
public static readonly Error BumperTemplateNotFound = Error.NotFound(
|
||||
"Channels.BumperTemplateNotFound",
|
||||
"Блок заставки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error JingleNotFound = Error.NotFound(
|
||||
"Channels.JingleNotFound",
|
||||
"Джингл не найден в пуле канала."
|
||||
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
|
||||
"Channels.CannotRemoveDefaultBumperTemplate",
|
||||
"Дефолтный блок заставки удалить нельзя."
|
||||
);
|
||||
|
||||
public static readonly Error AssetNotFound = Error.NotFound(
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
var channel = await dbContext.Channels.AsNoTracking()
|
||||
.Include(c => c.Shows)
|
||||
.Include(c => c.Ads)
|
||||
.Include(c => c.Jingles)
|
||||
.Include(c => c.BumperTemplates)
|
||||
.Include(c => c.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.AsSplitQuery()
|
||||
@@ -33,10 +33,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
|
||||
var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId)
|
||||
.Concat(channel.Jingles.Select(j => j.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId).Distinct().ToList();
|
||||
var assetNames = await dbContext.MediaAssets.AsNoTracking()
|
||||
.Where(a => poolAssetIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, a.OriginalFileName })
|
||||
@@ -67,13 +64,20 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var jingles = channel.Jingles
|
||||
.OrderBy(j => j.Position)
|
||||
.Select(j => new ChannelJingleDto(
|
||||
j.Id,
|
||||
j.MediaAssetId,
|
||||
assetNames.GetValueOrDefault(j.MediaAssetId),
|
||||
j.Position
|
||||
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.BackgroundImageExtension is not null,
|
||||
t.AudioExtension is not null,
|
||||
t.AudioDurationSeconds
|
||||
))
|
||||
.ToList();
|
||||
|
||||
@@ -100,24 +104,17 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
channel.AdsPerBreak,
|
||||
channel.BumpersEnabled,
|
||||
new BumperSettingsDto(
|
||||
channel.BumperMode,
|
||||
channel.BumperDurationSeconds,
|
||||
channel.BumperBackgroundColor,
|
||||
channel.BumperBackgroundColor2,
|
||||
channel.BumperAccentColor,
|
||||
channel.BumperTextColor,
|
||||
channel.BumperFont,
|
||||
channel.BumperNowLabel,
|
||||
channel.BumperNextLabel,
|
||||
channel.BumperMinIntervalMinutes,
|
||||
channel.BumperOnlyBetweenDifferentShows,
|
||||
channel.BumperBackgroundExtension is not null,
|
||||
channel.BumperMusicExtension is not null
|
||||
channel.BumperSelection
|
||||
),
|
||||
bumperTemplates,
|
||||
channel.FillerAssetId,
|
||||
shows,
|
||||
ads,
|
||||
jingles,
|
||||
overrides
|
||||
)
|
||||
);
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelJingle;
|
||||
|
||||
public sealed record RemoveChannelJingleCommand(Guid ChannelId, Guid ChannelJingleId)
|
||||
: ICommand<Result>;
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelJingle;
|
||||
|
||||
public sealed class RemoveChannelJingleCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveChannelJingleCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveChannelJingleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels
|
||||
.Include(c => c.Jingles)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
return channel.RemoveJingle(command.ChannelJingleId)
|
||||
? Result.Success()
|
||||
: Result.Failure(ChannelErrors.JingleNotFound);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
|
||||
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
|
||||
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
|
||||
/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) и подставляются
|
||||
/// как обычные ассеты.
|
||||
/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) по выбранному
|
||||
/// блоку и подставляются как обычные ассеты.
|
||||
/// </summary>
|
||||
public sealed class ScheduleGenerator(
|
||||
IAppDbContext dbContext,
|
||||
@@ -35,6 +35,9 @@ public sealed class ScheduleGenerator(
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
|
||||
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
|
||||
private const int DefaultBumperDurationSeconds = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
|
||||
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
|
||||
@@ -49,7 +52,7 @@ public sealed class ScheduleGenerator(
|
||||
var channel = await dbContext.Channels
|
||||
.Include(c => c.Shows)
|
||||
.Include(c => c.Ads)
|
||||
.Include(c => c.Jingles)
|
||||
.Include(c => c.BumperTemplates)
|
||||
.Include(c => c.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.AsSplitQuery()
|
||||
@@ -92,7 +95,7 @@ public sealed class ScheduleGenerator(
|
||||
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
|
||||
var result = SchedulePlanner.Plan(input, random);
|
||||
|
||||
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана.
|
||||
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + блоку).
|
||||
var bumperAssets = await ResolveBumperAssetsAsync(
|
||||
channel,
|
||||
result.Entries,
|
||||
@@ -135,7 +138,7 @@ public sealed class ScheduleGenerator(
|
||||
channelShow.SetNextEpisodeIndex(idx);
|
||||
|
||||
channel.SetNextAdIndex(result.NextAdIndex);
|
||||
channel.SetNextJingleIndex(result.NextJingleIndex);
|
||||
channel.SetNextBumperIndex(result.NextBumperIndex);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return added;
|
||||
@@ -144,57 +147,56 @@ public sealed class ScheduleGenerator(
|
||||
private static ScheduleEntry? BuildBumperEntry(
|
||||
Guid channelId,
|
||||
PlannedEntry entry,
|
||||
IReadOnlyDictionary<(Guid, Guid), Guid> bumperAssets
|
||||
IReadOnlyDictionary<(Guid From, Guid To, Guid Template), Guid> bumperAssets
|
||||
)
|
||||
{
|
||||
// Статичный джингл — планировщик уже проставил реальный ассет из пула.
|
||||
if (entry.MediaAssetId != Guid.Empty)
|
||||
return ScheduleEntry.Bumper(
|
||||
channelId,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
entry.ShowId
|
||||
);
|
||||
|
||||
// Динамическая заставка — ассет резолвится по паре шоу (отрендерен/из кэша).
|
||||
// Заставка резолвится по паре шоу + выбранному блоку (отрендерена/из кэша). Если рендер не
|
||||
// удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл).
|
||||
if (
|
||||
entry.FromShowId is not { } from
|
||||
|| entry.ToShowId is not { } to
|
||||
|| !bumperAssets.TryGetValue((from, to), out var assetId)
|
||||
|| entry.BumperTemplateId is not { } template
|
||||
|| !bumperAssets.TryGetValue((from, to, template), out var assetId)
|
||||
)
|
||||
// Заставку не удалось отрендерить — пропускаем запись (слот заполнит филлер/следующая
|
||||
// программа). Планировщик уже учёл её длину, поэтому небольшой зазор допустим.
|
||||
return null;
|
||||
|
||||
return ScheduleEntry.Bumper(channelId, assetId, entry.StartsAtUtc, entry.EndsAtUtc, entry.ToShowId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Для каждой уникальной пары «из→в» из запланированных заставок возвращает id готового
|
||||
/// Для каждой уникальной тройки «из→в→блок» из запланированных заставок возвращает id готового
|
||||
/// ассета-заставки: из кэша (<see cref="BumperAsset"/>) либо свежесгенерированного.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<(Guid From, Guid To), Guid>> ResolveBumperAssetsAsync(
|
||||
private async Task<Dictionary<(Guid From, Guid To, Guid Template), Guid>> ResolveBumperAssetsAsync(
|
||||
Channel channel,
|
||||
IReadOnlyList<PlannedEntry> entries,
|
||||
IReadOnlyDictionary<Guid, string> showNames,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = new Dictionary<(Guid, Guid), Guid>();
|
||||
var styleSignature = BumperStyleSignature(channel);
|
||||
var pairs = entries
|
||||
.Where(e => e.Kind == ScheduleEntryKind.Bumper && e.FromShowId is not null && e.ToShowId is not null)
|
||||
.Select(e => (From: e.FromShowId!.Value, To: e.ToShowId!.Value))
|
||||
var result = new Dictionary<(Guid, Guid, Guid), Guid>();
|
||||
var templatesById = channel.BumperTemplates.ToDictionary(t => t.Id);
|
||||
var combos = entries
|
||||
.Where(e =>
|
||||
e.Kind == ScheduleEntryKind.Bumper
|
||||
&& e.FromShowId is not null
|
||||
&& e.ToShowId is not null
|
||||
&& e.BumperTemplateId is not null
|
||||
)
|
||||
.Select(e => (
|
||||
From: e.FromShowId!.Value,
|
||||
To: e.ToShowId!.Value,
|
||||
Template: e.BumperTemplateId!.Value
|
||||
))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (pairs.Count == 0)
|
||||
if (combos.Count == 0)
|
||||
return result;
|
||||
|
||||
var fromIds = pairs.Select(p => p.From).Distinct().ToList();
|
||||
var toIds = pairs.Select(p => p.To).Distinct().ToList();
|
||||
var fromIds = combos.Select(c => c.From).Distinct().ToList();
|
||||
var toIds = combos.Select(c => c.To).Distinct().ToList();
|
||||
|
||||
// Постеры шоу-получателей — как фон заставки (если у канала нет своего фона).
|
||||
// Постеры шоу-получателей — как фон заставки (если у блока нет своей фон-картинки).
|
||||
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
||||
var posterByShow = await dbContext.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id) && s.PosterPath != null)
|
||||
@@ -220,22 +222,26 @@ public sealed class ScheduleGenerator(
|
||||
.ToListAsync(cancellationToken);
|
||||
var readySet = readyAssetIds.ToHashSet();
|
||||
|
||||
foreach (var pair in pairs)
|
||||
foreach (var combo in combos)
|
||||
{
|
||||
var fromName = showNames.GetValueOrDefault(pair.From, "…");
|
||||
var toName = showNames.GetValueOrDefault(pair.To, "…");
|
||||
var toPosterRel = posterByShow.GetValueOrDefault(pair.To);
|
||||
var signature = ComputeSignature(fromName, toName, styleSignature, toPosterRel ?? "-");
|
||||
if (!templatesById.TryGetValue(combo.Template, out var template))
|
||||
continue;
|
||||
|
||||
var fromName = showNames.GetValueOrDefault(combo.From, "…");
|
||||
var toName = showNames.GetValueOrDefault(combo.To, "…");
|
||||
var toPosterRel = posterByShow.GetValueOrDefault(combo.To);
|
||||
var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template));
|
||||
var signature = ComputeSignature(channel, template, fromName, toName, aligned, toPosterRel ?? "-");
|
||||
|
||||
var hit = cached.FirstOrDefault(c =>
|
||||
c.FromShowId == pair.From
|
||||
&& c.ToShowId == pair.To
|
||||
c.FromShowId == combo.From
|
||||
&& c.ToShowId == combo.To
|
||||
&& c.Signature == signature
|
||||
&& readySet.Contains(c.MediaAssetId)
|
||||
);
|
||||
if (hit is not null)
|
||||
{
|
||||
result[pair] = hit.MediaAssetId;
|
||||
result[combo] = hit.MediaAssetId;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -243,15 +249,17 @@ public sealed class ScheduleGenerator(
|
||||
{
|
||||
var assetId = await RenderBumperAsync(
|
||||
channel,
|
||||
pair.From,
|
||||
pair.To,
|
||||
template,
|
||||
combo.From,
|
||||
combo.To,
|
||||
fromName,
|
||||
toName,
|
||||
aligned,
|
||||
signature,
|
||||
toPosterRel,
|
||||
cancellationToken
|
||||
);
|
||||
result[pair] = assetId;
|
||||
result[combo] = assetId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -269,10 +277,12 @@ public sealed class ScheduleGenerator(
|
||||
|
||||
private async Task<Guid> RenderBumperAsync(
|
||||
Channel channel,
|
||||
BumperTemplate template,
|
||||
Guid fromShowId,
|
||||
Guid toShowId,
|
||||
string fromName,
|
||||
string toName,
|
||||
int alignedDurationSeconds,
|
||||
string signature,
|
||||
string? toPosterRelative,
|
||||
CancellationToken cancellationToken
|
||||
@@ -284,7 +294,7 @@ public sealed class ScheduleGenerator(
|
||||
: metadataImages.ResolveAbsolutePath(toPosterRelative);
|
||||
var render = await bumperRenderer.RenderAsync(
|
||||
asset.Id,
|
||||
BuildSpec(channel, fromName, toName, posterAbs),
|
||||
BuildSpec(channel, template, alignedDurationSeconds, fromName, toName, posterAbs),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
@@ -308,67 +318,78 @@ public sealed class ScheduleGenerator(
|
||||
|
||||
private BumperRenderSpec BuildSpec(
|
||||
Channel channel,
|
||||
BumperTemplate template,
|
||||
int alignedDurationSeconds,
|
||||
string fromName,
|
||||
string toName,
|
||||
string? posterAbsolutePath
|
||||
) =>
|
||||
new(
|
||||
AlignedBumperDuration(channel),
|
||||
alignedDurationSeconds,
|
||||
_bumper.Width,
|
||||
_bumper.Height,
|
||||
channel.BumperBackgroundColor,
|
||||
channel.BumperBackgroundColor2,
|
||||
channel.BumperAccentColor,
|
||||
channel.BumperTextColor,
|
||||
template.BackgroundColor,
|
||||
template.BackgroundColor2,
|
||||
template.AccentColor,
|
||||
template.TextColor,
|
||||
FontPath(channel.BumperFont),
|
||||
channel.BumperNowLabel,
|
||||
fromName,
|
||||
channel.BumperNextLabel,
|
||||
toName,
|
||||
bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension),
|
||||
bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension),
|
||||
bumperStorage.BackgroundPath(template.Id, template.BackgroundImageExtension),
|
||||
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
||||
posterAbsolutePath
|
||||
);
|
||||
|
||||
private string FontPath(BumperFont font) =>
|
||||
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
||||
|
||||
/// <summary>Длительность заставки канала, выровненная вверх до кратности сегменту.</summary>
|
||||
private int AlignedBumperDuration(Channel channel)
|
||||
/// <summary>Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза.</summary>
|
||||
private static double TemplateDurationSeconds(BumperTemplate template) =>
|
||||
template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds;
|
||||
|
||||
/// <summary>Длительность, выровненная вверх до кратности длине сегмента (инвариант раздачи).</summary>
|
||||
private int AlignedDurationSeconds(double seconds)
|
||||
{
|
||||
var requested = Math.Max(_segmentSeconds, channel.BumperDurationSeconds);
|
||||
return (int)(Math.Ceiling((double)requested / _segmentSeconds) * _segmentSeconds);
|
||||
var requested = Math.Max(_segmentSeconds, seconds);
|
||||
return (int)(Math.Ceiling(requested / _segmentSeconds) * _segmentSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Сигнатура оформления канала — входит в кэш-ключ, чтобы правка стиля пересобирала заставки.</summary>
|
||||
private string BumperStyleSignature(Channel channel) =>
|
||||
string.Join(
|
||||
'|',
|
||||
_bumper.TemplateVersion,
|
||||
AlignedBumperDuration(channel),
|
||||
_bumper.Width,
|
||||
_bumper.Height,
|
||||
channel.BumperBackgroundColor,
|
||||
channel.BumperBackgroundColor2,
|
||||
channel.BumperAccentColor,
|
||||
channel.BumperTextColor,
|
||||
channel.BumperFont,
|
||||
channel.BumperNowLabel,
|
||||
channel.BumperNextLabel,
|
||||
// Ревизия + расширения файлов: замена загруженного фона/музыки пересобирает заставки.
|
||||
channel.BumperRevision,
|
||||
channel.BumperBackgroundExtension ?? "-",
|
||||
channel.BumperMusicExtension ?? "-"
|
||||
);
|
||||
|
||||
private static string ComputeSignature(
|
||||
/// <summary>
|
||||
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
|
||||
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
|
||||
/// заставка пересобирается.
|
||||
/// </summary>
|
||||
private string ComputeSignature(
|
||||
Channel channel,
|
||||
BumperTemplate template,
|
||||
string fromName,
|
||||
string toName,
|
||||
string styleSignature,
|
||||
int alignedDurationSeconds,
|
||||
string poster
|
||||
)
|
||||
{
|
||||
var raw = string.Join('', fromName, toName, styleSignature, poster);
|
||||
var raw = string.Join(
|
||||
'',
|
||||
_bumper.TemplateVersion,
|
||||
_bumper.Width,
|
||||
_bumper.Height,
|
||||
alignedDurationSeconds,
|
||||
channel.BumperFont,
|
||||
channel.BumperNowLabel,
|
||||
channel.BumperNextLabel,
|
||||
template.BackgroundColor,
|
||||
template.BackgroundColor2,
|
||||
template.AccentColor,
|
||||
template.TextColor,
|
||||
template.Revision,
|
||||
template.BackgroundImageExtension ?? "-",
|
||||
template.AudioExtension ?? "-",
|
||||
fromName,
|
||||
toName,
|
||||
poster
|
||||
);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
@@ -411,7 +432,6 @@ public sealed class ScheduleGenerator(
|
||||
var candidateAssetIds = episodesByShow.Values
|
||||
.SelectMany(x => x)
|
||||
.Concat(channel.Ads.Select(a => a.MediaAssetId))
|
||||
.Concat(channel.Jingles.Select(j => j.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
@@ -452,10 +472,13 @@ public sealed class ScheduleGenerator(
|
||||
.Where(durations.ContainsKey)
|
||||
.ToList();
|
||||
|
||||
var jinglePool = channel.Jingles
|
||||
.OrderBy(j => j.Position)
|
||||
.Select(j => j.MediaAssetId)
|
||||
.Where(durations.ContainsKey)
|
||||
// Блоки заставок: длительность слота — по звуку (или дефолт), выровнена на сегмент.
|
||||
var bumperTemplates = channel.BumperTemplates
|
||||
.OrderBy(t => t.Position)
|
||||
.Select(t => new PlannerBumperTemplate(
|
||||
t.Id,
|
||||
TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t)))
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var overrides = channel.Overrides
|
||||
@@ -469,11 +492,10 @@ public sealed class ScheduleGenerator(
|
||||
|
||||
var bumpers = new PlannerBumperConfig(
|
||||
channel.BumpersEnabled,
|
||||
TimeSpan.FromSeconds(AlignedBumperDuration(channel)),
|
||||
channel.BumperOnlyBetweenDifferentShows,
|
||||
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
|
||||
channel.BumperMode,
|
||||
jinglePool
|
||||
channel.BumperSelection,
|
||||
bumperTemplates
|
||||
);
|
||||
|
||||
return new PlannerInput(
|
||||
@@ -488,7 +510,7 @@ public sealed class ScheduleGenerator(
|
||||
startTime,
|
||||
horizonEnd,
|
||||
bumpers,
|
||||
channel.NextJingleIndex
|
||||
channel.NextBumperIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-8
@@ -15,17 +15,12 @@ public sealed record UpdateChannelSettingsCommand(
|
||||
Guid? FillerAssetId
|
||||
) : ICommand<Result>;
|
||||
|
||||
/// <summary>Оформление и правила ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
|
||||
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
|
||||
public sealed record BumperSettingsInput(
|
||||
BumperMode Mode,
|
||||
int DurationSeconds,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
BumperFont Font,
|
||||
string NowLabel,
|
||||
string NextLabel,
|
||||
int MinIntervalMinutes,
|
||||
bool OnlyBetweenDifferentShows
|
||||
bool OnlyBetweenDifferentShows,
|
||||
BumperSelection Selection
|
||||
);
|
||||
|
||||
+2
-7
@@ -36,17 +36,12 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
||||
command.FillerAssetId
|
||||
);
|
||||
channel.UpdateBumperSettings(
|
||||
command.Bumper.Mode,
|
||||
command.Bumper.DurationSeconds,
|
||||
command.Bumper.BackgroundColor,
|
||||
command.Bumper.BackgroundColor2,
|
||||
command.Bumper.AccentColor,
|
||||
command.Bumper.TextColor,
|
||||
command.Bumper.Font,
|
||||
command.Bumper.NowLabel,
|
||||
command.Bumper.NextLabel,
|
||||
command.Bumper.MinIntervalMinutes,
|
||||
command.Bumper.OnlyBetweenDifferentShows
|
||||
command.Bumper.OnlyBetweenDifferentShows,
|
||||
command.Bumper.Selection
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
+1
-19
@@ -1,9 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
public sealed partial class UpdateChannelSettingsCommandValidator
|
||||
public sealed class UpdateChannelSettingsCommandValidator
|
||||
: AbstractValidator<UpdateChannelSettingsCommand>
|
||||
{
|
||||
public UpdateChannelSettingsCommandValidator()
|
||||
@@ -11,25 +10,8 @@ public sealed partial class UpdateChannelSettingsCommandValidator
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10);
|
||||
|
||||
RuleFor(x => x.Bumper.DurationSeconds).InclusiveBetween(2, 30);
|
||||
RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
|
||||
RuleFor(x => x.Bumper.NowLabel).MaximumLength(64);
|
||||
RuleFor(x => x.Bumper.NextLabel).MaximumLength(64);
|
||||
|
||||
// Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат
|
||||
// (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра.
|
||||
RuleFor(x => x.Bumper.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
RuleFor(x => x.Bumper.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
RuleFor(x => x.Bumper.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
||||
RuleFor(x => x.Bumper.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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>Замер длительности аудиофайла (ffprobe). Нужен, чтобы длина заставки шла по длине звука.</summary>
|
||||
public interface IAudioProbe
|
||||
{
|
||||
/// <summary>Длительность файла по абсолютному пути или null, если определить не удалось.</summary>
|
||||
Task<TimeSpan?> TryGetDurationAsync(string absolutePath, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,31 +1,34 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Хранилище сырых файлов шаблона заставки канала (фон и музыка) под bumpers/{channelId}. В отличие
|
||||
/// Хранилище сырых файлов блоков заставок (звук и фон-картинка) под bumpers/{templateId}. В отличие
|
||||
/// от обычных ассетов эти файлы НЕ режутся на HLS — они подаются как входы в рендер заставки.
|
||||
/// </summary>
|
||||
public interface IBumperTemplateStorage
|
||||
{
|
||||
Task SaveAudioAsync(
|
||||
Guid templateId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task SaveBackgroundAsync(
|
||||
Guid channelId,
|
||||
Guid templateId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task SaveMusicAsync(
|
||||
Guid channelId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
void DeleteAudio(Guid templateId);
|
||||
void DeleteBackground(Guid templateId);
|
||||
|
||||
void DeleteBackground(Guid channelId);
|
||||
void DeleteMusic(Guid channelId);
|
||||
/// <summary>Удалить все файлы блока (при удалении самого блока).</summary>
|
||||
void DeleteTemplate(Guid templateId);
|
||||
|
||||
/// <summary>Абсолютный путь к загруженному фону или null (нет расширения / файл отсутствует).</summary>
|
||||
string? BackgroundPath(Guid channelId, string? extension);
|
||||
/// <summary>Абсолютный путь к загруженному звуку или null (нет расширения / файл отсутствует).</summary>
|
||||
string? AudioPath(Guid templateId, string? extension);
|
||||
|
||||
/// <summary>Абсолютный путь к загруженной музыке или null.</summary>
|
||||
string? MusicPath(Guid channelId, string? extension);
|
||||
/// <summary>Абсолютный путь к загруженной фон-картинке или null.</summary>
|
||||
string? BackgroundPath(Guid templateId, string? extension);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace TeleWave.Domain.Broadcast;
|
||||
|
||||
/// <summary>Какие заставки вставлять на переходах.</summary>
|
||||
public enum BumperMode
|
||||
{
|
||||
/// <summary>Только динамические «Сейчас/Далее», отрисованные по оформлению канала.</summary>
|
||||
Dynamic,
|
||||
|
||||
/// <summary>Только готовые ролики-джинглы из пула канала (по кругу).</summary>
|
||||
Static,
|
||||
|
||||
/// <summary>И то, и другое — чередуя на соседних переходах.</summary>
|
||||
Both,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TeleWave.Domain.Broadcast;
|
||||
|
||||
/// <summary>Как выбирать блок заставки на каждом переходе между шоу.</summary>
|
||||
public enum BumperSelection
|
||||
{
|
||||
/// <summary>По кругу в порядке блоков (курсор <see cref="Channel.NextBumperIndex"/>).</summary>
|
||||
Rotation,
|
||||
|
||||
/// <summary>Случайный блок на каждом переходе.</summary>
|
||||
Random,
|
||||
|
||||
/// <summary>Всегда первый (дефолтный) блок.</summary>
|
||||
AlwaysFirst,
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
namespace TeleWave.Domain.Broadcast;
|
||||
|
||||
/// <summary>
|
||||
/// Блок ТВ-заставки канала: свой звук + своё оформление (цвета, опциональная фон-картинка). На
|
||||
/// переходе между шоу генератор рендерит «Сейчас/Далее» стилем блока поверх его звука; длительность
|
||||
/// заставки определяется длиной звука (выравнивается на сегмент при рендере). Общие для канала шрифт,
|
||||
/// подписи и правила показа живут на <see cref="Channel"/>.
|
||||
///
|
||||
/// Первый блок (<see cref="Position"/> == 0) — дефолтный, не удаляется; если звук в нём не загружен,
|
||||
/// рендер синтезирует джингл по умолчанию.
|
||||
/// </summary>
|
||||
public class BumperTemplate
|
||||
{
|
||||
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;
|
||||
|
||||
// ── Оформление блока (цвета — в нотации ffmpeg: 0xRRGGBB или имя) ──
|
||||
public string BackgroundColor { get; private set; } = DefaultBackgroundColor;
|
||||
public string BackgroundColor2 { get; private set; } = DefaultBackgroundColor2;
|
||||
public string AccentColor { get; private set; } = DefaultAccentColor;
|
||||
public string TextColor { get; private set; } = DefaultTextColor;
|
||||
|
||||
/// <summary>Расширение загруженной фон-картинки (с точкой) или null — тогда фон градиент/постер.</summary>
|
||||
public string? BackgroundImageExtension { get; private set; }
|
||||
|
||||
/// <summary>Расширение загруженного звука (с точкой) или null — тогда синтезируется джингл.</summary>
|
||||
public string? AudioExtension { get; private set; }
|
||||
|
||||
/// <summary>Длина загруженного звука в секундах (замер ffprobe) или null, если звука нет.</summary>
|
||||
public double? AudioDurationSeconds { get; private set; }
|
||||
|
||||
/// <summary>Версия файлов блока (звук/фон). Входит в кэш-ключ рендера — замена файла пересобирает заставки.</summary>
|
||||
public int Revision { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
public const string DefaultBackgroundColor = "0x0b1020";
|
||||
public const string DefaultBackgroundColor2 = "0x1e293b";
|
||||
public const string DefaultAccentColor = "0x38bdf8";
|
||||
public const string DefaultTextColor = "white";
|
||||
|
||||
public bool IsDefault => Position == 0;
|
||||
|
||||
private BumperTemplate() { }
|
||||
|
||||
internal static BumperTemplate Create(Guid channelId, int position, string name) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChannelId = channelId,
|
||||
Position = position,
|
||||
Name = name,
|
||||
BackgroundColor = DefaultBackgroundColor,
|
||||
BackgroundColor2 = DefaultBackgroundColor2,
|
||||
AccentColor = DefaultAccentColor,
|
||||
TextColor = DefaultTextColor,
|
||||
BackgroundImageExtension = null,
|
||||
AudioExtension = null,
|
||||
AudioDurationSeconds = null,
|
||||
Revision = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>Обновить имя и цвета блока. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</summary>
|
||||
public void UpdateStyle(
|
||||
string name,
|
||||
string backgroundColor,
|
||||
string backgroundColor2,
|
||||
string accentColor,
|
||||
string textColor
|
||||
)
|
||||
{
|
||||
Name = name;
|
||||
BackgroundColor = backgroundColor;
|
||||
BackgroundColor2 = backgroundColor2;
|
||||
AccentColor = accentColor;
|
||||
TextColor = textColor;
|
||||
}
|
||||
|
||||
/// <summary>Отметить загруженный звук (extension — с точкой, нижний регистр) и его длину. Меняет ревизию.</summary>
|
||||
public void SetAudio(string extension, double durationSeconds)
|
||||
{
|
||||
AudioExtension = extension;
|
||||
AudioDurationSeconds = durationSeconds > 0 ? durationSeconds : null;
|
||||
Revision++;
|
||||
}
|
||||
|
||||
public void ClearAudio()
|
||||
{
|
||||
if (AudioExtension is null && AudioDurationSeconds is null)
|
||||
return;
|
||||
AudioExtension = null;
|
||||
AudioDurationSeconds = null;
|
||||
Revision++;
|
||||
}
|
||||
|
||||
/// <summary>Отметить загруженную фон-картинку (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
|
||||
public void SetBackgroundImage(string extension)
|
||||
{
|
||||
BackgroundImageExtension = extension;
|
||||
Revision++;
|
||||
}
|
||||
|
||||
public void ClearBackgroundImage()
|
||||
{
|
||||
if (BackgroundImageExtension is null)
|
||||
return;
|
||||
BackgroundImageExtension = null;
|
||||
Revision++;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public class Channel
|
||||
private readonly List<ChannelShow> _shows = new();
|
||||
private readonly List<ChannelAd> _ads = new();
|
||||
private readonly List<ProgrammingOverride> _overrides = new();
|
||||
private readonly List<ChannelJingle> _jingles = new();
|
||||
private readonly List<BumperTemplate> _bumperTemplates = new();
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
@@ -26,28 +26,14 @@ public class Channel
|
||||
/// <summary>Вставлять ли ТВ-заставки на переходах между разными шоу.</summary>
|
||||
public bool BumpersEnabled { get; private set; }
|
||||
|
||||
/// <summary>Какие заставки вставлять: динамические «Сейчас/Далее», статичные джинглы или оба.</summary>
|
||||
public BumperMode BumperMode { get; private set; }
|
||||
// ── Общие для канала настройки ТВ-заставок (стиль/звук — на каждом блоке, см. BumperTemplates) ──
|
||||
|
||||
/// <summary>Расширение загруженного фона (с точкой) или null — тогда синтезируется градиент.</summary>
|
||||
public string? BumperBackgroundExtension { get; private set; }
|
||||
/// <summary>Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый).</summary>
|
||||
public BumperSelection BumperSelection { get; private set; }
|
||||
|
||||
/// <summary>Расширение загруженной музыки (с точкой) или null — тогда синтезируется джингл.</summary>
|
||||
public string? BumperMusicExtension { get; private set; }
|
||||
/// <summary>Курсор ротации блоков заставок.</summary>
|
||||
public int NextBumperIndex { get; private set; }
|
||||
|
||||
/// <summary>Счётчик версии файлов заставки (фон/музыка). Входит в кэш-ключ, чтобы замена файла тем
|
||||
/// же именем пересобирала уже отрендеренные динамические заставки.</summary>
|
||||
public int BumperRevision { get; private set; }
|
||||
|
||||
/// <summary>Курсор ротации пула джинглов.</summary>
|
||||
public int NextJingleIndex { get; private set; }
|
||||
|
||||
// ── Оформление и правила ТВ-заставок (значения на канал; см. UpdateBumperSettings) ──
|
||||
public int BumperDurationSeconds { get; private set; }
|
||||
public string BumperBackgroundColor { get; private set; } = DefaultBackgroundColor;
|
||||
public string BumperBackgroundColor2 { get; private set; } = DefaultBackgroundColor2;
|
||||
public string BumperAccentColor { get; private set; } = DefaultAccentColor;
|
||||
public string BumperTextColor { get; private set; } = DefaultTextColor;
|
||||
public BumperFont BumperFont { get; private set; }
|
||||
public string BumperNowLabel { get; private set; } = DefaultNowLabel;
|
||||
public string BumperNextLabel { get; private set; } = DefaultNextLabel;
|
||||
@@ -58,13 +44,9 @@ public class Channel
|
||||
/// <summary>Ставить заставку только на смене шоу (иначе — и внутри марафона одного шоу).</summary>
|
||||
public bool BumperOnlyBetweenDifferentShows { get; private set; }
|
||||
|
||||
private const int DefaultBumperDurationSeconds = 8;
|
||||
private const string DefaultBackgroundColor = "0x0b1020";
|
||||
private const string DefaultBackgroundColor2 = "0x1e293b";
|
||||
private const string DefaultAccentColor = "0x38bdf8";
|
||||
private const string DefaultTextColor = "white";
|
||||
private const string DefaultNowLabel = "СЕЙЧАС";
|
||||
private const string DefaultNextLabel = "ДАЛЕЕ";
|
||||
private const string DefaultTemplateName = "Заставка 1";
|
||||
|
||||
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
||||
public Guid? FillerAssetId { get; private set; }
|
||||
@@ -80,13 +62,14 @@ public class Channel
|
||||
public IReadOnlyList<ChannelAd> Ads => _ads;
|
||||
public IReadOnlyList<ProgrammingOverride> Overrides => _overrides;
|
||||
|
||||
/// <summary>Пул джинглов-отбивок; порядок ротации — по <see cref="ChannelJingle.Position"/>.</summary>
|
||||
public IReadOnlyList<ChannelJingle> Jingles => _jingles;
|
||||
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
|
||||
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
|
||||
|
||||
private Channel() { }
|
||||
|
||||
public static Channel Create(string name, string slug, DateTimeOffset epochUtc) =>
|
||||
new()
|
||||
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
|
||||
{
|
||||
var channel = new Channel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
@@ -96,16 +79,8 @@ public class Channel
|
||||
AdInsertion = AdInsertion.BetweenBlocks,
|
||||
AdsPerBreak = 1,
|
||||
BumpersEnabled = false,
|
||||
BumperMode = BumperMode.Dynamic,
|
||||
BumperBackgroundExtension = null,
|
||||
BumperMusicExtension = null,
|
||||
BumperRevision = 0,
|
||||
NextJingleIndex = 0,
|
||||
BumperDurationSeconds = DefaultBumperDurationSeconds,
|
||||
BumperBackgroundColor = DefaultBackgroundColor,
|
||||
BumperBackgroundColor2 = DefaultBackgroundColor2,
|
||||
BumperAccentColor = DefaultAccentColor,
|
||||
BumperTextColor = DefaultTextColor,
|
||||
BumperSelection = BumperSelection.Rotation,
|
||||
NextBumperIndex = 0,
|
||||
BumperFont = BumperFont.Sans,
|
||||
BumperNowLabel = DefaultNowLabel,
|
||||
BumperNextLabel = DefaultNextLabel,
|
||||
@@ -114,6 +89,10 @@ public class Channel
|
||||
NextAdIndex = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
|
||||
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
|
||||
return channel;
|
||||
}
|
||||
|
||||
public void UpdateSettings(
|
||||
string name,
|
||||
@@ -132,85 +111,48 @@ public class Channel
|
||||
FillerAssetId = fillerAssetId;
|
||||
}
|
||||
|
||||
/// <summary>Оформление и правила ТВ-заставок канала. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</summary>
|
||||
/// <summary>Общие настройки ТВ-заставок канала: шрифт, подписи, правила показа и стратегия выбора блока.</summary>
|
||||
public void UpdateBumperSettings(
|
||||
BumperMode mode,
|
||||
int durationSeconds,
|
||||
string backgroundColor,
|
||||
string backgroundColor2,
|
||||
string accentColor,
|
||||
string textColor,
|
||||
BumperFont font,
|
||||
string nowLabel,
|
||||
string nextLabel,
|
||||
int minIntervalMinutes,
|
||||
bool onlyBetweenDifferentShows
|
||||
bool onlyBetweenDifferentShows,
|
||||
BumperSelection selection
|
||||
)
|
||||
{
|
||||
BumperMode = mode;
|
||||
BumperDurationSeconds = durationSeconds;
|
||||
BumperBackgroundColor = backgroundColor;
|
||||
BumperBackgroundColor2 = backgroundColor2;
|
||||
BumperAccentColor = accentColor;
|
||||
BumperTextColor = textColor;
|
||||
BumperFont = font;
|
||||
BumperNowLabel = nowLabel;
|
||||
BumperNextLabel = nextLabel;
|
||||
BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes);
|
||||
BumperOnlyBetweenDifferentShows = onlyBetweenDifferentShows;
|
||||
BumperSelection = selection;
|
||||
}
|
||||
|
||||
/// <summary>Отметить загруженный фон (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
|
||||
public void SetBumperBackground(string extension)
|
||||
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
|
||||
public BumperTemplate AddBumperTemplate(string name)
|
||||
{
|
||||
BumperBackgroundExtension = extension;
|
||||
BumperRevision++;
|
||||
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 void ClearBumperBackground()
|
||||
{
|
||||
if (BumperBackgroundExtension is null)
|
||||
return;
|
||||
BumperBackgroundExtension = null;
|
||||
BumperRevision++;
|
||||
}
|
||||
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
|
||||
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
||||
|
||||
/// <summary>Отметить загруженную музыку (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
|
||||
public void SetBumperMusic(string extension)
|
||||
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
|
||||
public bool RemoveBumperTemplate(Guid templateId)
|
||||
{
|
||||
BumperMusicExtension = extension;
|
||||
BumperRevision++;
|
||||
}
|
||||
|
||||
public void ClearBumperMusic()
|
||||
{
|
||||
if (BumperMusicExtension is null)
|
||||
return;
|
||||
BumperMusicExtension = null;
|
||||
BumperRevision++;
|
||||
}
|
||||
|
||||
public ChannelJingle AddJingle(Guid mediaAssetId)
|
||||
{
|
||||
var nextPosition = _jingles.Count == 0 ? 0 : _jingles.Max(j => j.Position) + 1;
|
||||
var jingle = ChannelJingle.Create(Id, mediaAssetId, nextPosition);
|
||||
_jingles.Add(jingle);
|
||||
return jingle;
|
||||
}
|
||||
|
||||
public bool RemoveJingle(Guid channelJingleId)
|
||||
{
|
||||
var jingle = _jingles.FirstOrDefault(j => j.Id == channelJingleId);
|
||||
if (jingle is null)
|
||||
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
||||
if (template is null || template.IsDefault)
|
||||
return false;
|
||||
_jingles.Remove(jingle);
|
||||
_bumperTemplates.Remove(template);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool HasJingle(Guid mediaAssetId) => _jingles.Any(j => j.MediaAssetId == mediaAssetId);
|
||||
|
||||
/// <summary>Планировщик двигает курсор пула джинглов по мере вставки отбивок.</summary>
|
||||
public void SetNextJingleIndex(int index) => NextJingleIndex = index;
|
||||
/// <summary>Планировщик двигает курсор ротации блоков заставок по мере вставки.</summary>
|
||||
public void SetNextBumperIndex(int index) => NextBumperIndex = index;
|
||||
|
||||
public ChannelShow? FindShow(Guid channelShowId) => _shows.FirstOrDefault(s => s.Id == channelShowId);
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace TeleWave.Domain.Broadcast;
|
||||
|
||||
/// <summary>Готовый ролик-джингл (отбивка) в пуле канала. Крутятся по кругу в порядке <see cref="Position"/>
|
||||
/// на переходах между шоу, когда режим заставок — Static или Both.</summary>
|
||||
public class ChannelJingle
|
||||
{
|
||||
public Guid Id { get; private set; }
|
||||
public Guid ChannelId { get; private set; }
|
||||
public Guid MediaAssetId { get; private set; }
|
||||
public int Position { get; private set; }
|
||||
|
||||
private ChannelJingle() { }
|
||||
|
||||
internal static ChannelJingle Create(Guid channelId, Guid mediaAssetId, int position) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChannelId = channelId,
|
||||
MediaAssetId = mediaAssetId,
|
||||
Position = position,
|
||||
};
|
||||
}
|
||||
@@ -19,18 +19,17 @@ public static class SchedulePlanner
|
||||
var byShowId = input.Shows.ToDictionary(s => s.ShowId);
|
||||
var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex);
|
||||
var nextAd = input.NextAdIndex;
|
||||
var nextJingle = input.NextJingleIndex;
|
||||
var nextBumper = input.NextBumperIndex;
|
||||
|
||||
// Есть ли вообще из чего строить эфир.
|
||||
var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0);
|
||||
if (!anyPlayable)
|
||||
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
|
||||
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
|
||||
|
||||
var cursor = input.StartTime;
|
||||
var iterations = 0;
|
||||
Guid? prevShowId = null;
|
||||
DateTimeOffset? lastBumperAt = null;
|
||||
var bumperCount = 0;
|
||||
|
||||
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
|
||||
{
|
||||
@@ -40,8 +39,8 @@ public static class SchedulePlanner
|
||||
|
||||
var pick = WeightedPick(candidates, random);
|
||||
|
||||
// ТВ-заставка на переходе. Динамическую (Сейчас/Далее) резервируем слотом фикс. длины —
|
||||
// ассет отрендерит оркестратор; статичный джингл берём готовым из пула (реальная длина).
|
||||
// ТВ-заставка на переходе. Резервируем слот выбранного блока фикс. длины — конкретный
|
||||
// отрендеренный ассет («Сейчас/Далее» стилем блока поверх его звука) подставит оркестратор.
|
||||
if (
|
||||
prevShowId is { } prev
|
||||
&& input.Bumpers is { Enabled: true } bumper
|
||||
@@ -54,11 +53,8 @@ public static class SchedulePlanner
|
||||
)
|
||||
{
|
||||
var bumperStart = cursor;
|
||||
if (TryPlaceBumper(entries, bumper, bumperCount, prev, pick.ShowId, input, ref nextJingle, ref cursor))
|
||||
{
|
||||
if (TryPlaceBumper(entries, bumper, prev, pick.ShowId, random, ref nextBumper, ref cursor))
|
||||
lastBumperAt = bumperStart;
|
||||
bumperCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var blockStart = cursor;
|
||||
@@ -94,75 +90,62 @@ public static class SchedulePlanner
|
||||
prevShowId = pick.ShowId;
|
||||
}
|
||||
|
||||
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
|
||||
return new PlannerResult(entries, nextEpisode, nextAd, nextBumper);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ставит одну заставку на переходе по режиму канала. Динамическая — плейсхолдер фикс. длины
|
||||
/// (ассет подставит оркестратор). Статичная — готовый джингл из пула (реальная длина, курсор
|
||||
/// двигается). В режиме Both типы чередуются; при пустом пуле Both уходит в динамику.
|
||||
/// Возвращает true, если заставка добавлена (курсор сдвинут).
|
||||
/// Ставит на переходе заставку выбранного блока: резервирует слот его длины и оставляет
|
||||
/// плейсхолдер с парой шоу + id блока (ассет отрендерит оркестратор). Выбор блока — по стратегии
|
||||
/// канала (ротация двигает курсор). Возвращает true, если заставка добавлена (курсор сдвинут).
|
||||
/// </summary>
|
||||
private static bool TryPlaceBumper(
|
||||
List<PlannedEntry> entries,
|
||||
PlannerBumperConfig bumper,
|
||||
int bumperCount,
|
||||
Guid fromShowId,
|
||||
Guid toShowId,
|
||||
PlannerInput input,
|
||||
ref int nextJingle,
|
||||
IRandomSource random,
|
||||
ref int nextBumper,
|
||||
ref DateTimeOffset cursor
|
||||
)
|
||||
{
|
||||
var pool = bumper.JinglePool;
|
||||
var hasPool = pool is { Count: > 0 };
|
||||
|
||||
var wantStatic =
|
||||
bumper.Mode == BumperMode.Static
|
||||
|| (bumper.Mode == BumperMode.Both && bumperCount % 2 == 1);
|
||||
|
||||
// В режиме Both при пустом пуле показываем динамику.
|
||||
if (wantStatic && !hasPool && bumper.Mode == BumperMode.Both)
|
||||
wantStatic = false;
|
||||
|
||||
if (wantStatic)
|
||||
{
|
||||
if (!hasPool)
|
||||
return false; // Static без пула — вставлять нечего.
|
||||
|
||||
var idx = ((nextJingle % pool!.Count) + pool.Count) % pool.Count;
|
||||
var assetId = pool[idx];
|
||||
nextJingle++;
|
||||
var dur = DurationOf(assetId, input);
|
||||
if (dur <= TimeSpan.Zero)
|
||||
return false;
|
||||
|
||||
var end = cursor + dur;
|
||||
entries.Add(
|
||||
new PlannedEntry(assetId, ScheduleEntryKind.Bumper, cursor, end, null, null)
|
||||
);
|
||||
cursor = end;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Динамическая заставка «Сейчас/Далее» — плейсхолдер с парой шоу для рендера.
|
||||
if (bumper.Duration <= TimeSpan.Zero)
|
||||
var templates = bumper.Templates;
|
||||
if (templates is not { Count: > 0 })
|
||||
return false;
|
||||
|
||||
var dynEnd = cursor + bumper.Duration;
|
||||
PlannerBumperTemplate template;
|
||||
switch (bumper.Selection)
|
||||
{
|
||||
case BumperSelection.Random:
|
||||
template = templates[random.Next(templates.Count)];
|
||||
break;
|
||||
case BumperSelection.AlwaysFirst:
|
||||
template = templates[0];
|
||||
break;
|
||||
default: // Rotation
|
||||
var idx = ((nextBumper % templates.Count) + templates.Count) % templates.Count;
|
||||
template = templates[idx];
|
||||
nextBumper++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (template.Duration <= TimeSpan.Zero)
|
||||
return false;
|
||||
|
||||
var end = cursor + template.Duration;
|
||||
entries.Add(
|
||||
new PlannedEntry(
|
||||
Guid.Empty,
|
||||
ScheduleEntryKind.Bumper,
|
||||
cursor,
|
||||
dynEnd,
|
||||
end,
|
||||
toShowId,
|
||||
null,
|
||||
fromShowId,
|
||||
toShowId
|
||||
toShowId,
|
||||
template.TemplateId
|
||||
)
|
||||
);
|
||||
cursor = dynEnd;
|
||||
cursor = end;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,19 +22,22 @@ public sealed record PlannerOverride(
|
||||
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
|
||||
|
||||
/// <summary>
|
||||
/// Политика ТВ-заставок на переходах. <see cref="Duration"/> должна быть кратна длине сегмента
|
||||
/// (генератор выравнивает). Планировщик резервирует под заставку слот этой длины, а конкретный
|
||||
/// сгенерированный ассет подставляет уже оркестратор.
|
||||
/// Политика ТВ-заставок на переходах. Планировщик выбирает блок (<see cref="Templates"/>) по
|
||||
/// стратегии <see cref="Selection"/> и резервирует слот его длины (<see cref="PlannerBumperTemplate.Duration"/>,
|
||||
/// уже выровнена генератором на сегмент). Конкретный отрендеренный ассет подставляет оркестратор
|
||||
/// по паре шоу + выбранному блоку.
|
||||
/// </summary>
|
||||
public sealed record PlannerBumperConfig(
|
||||
bool Enabled,
|
||||
TimeSpan Duration,
|
||||
bool OnlyBetweenDifferentShows,
|
||||
TimeSpan MinInterval,
|
||||
BumperMode Mode = BumperMode.Dynamic,
|
||||
IReadOnlyList<Guid>? JinglePool = null
|
||||
BumperSelection Selection,
|
||||
IReadOnlyList<PlannerBumperTemplate> Templates
|
||||
);
|
||||
|
||||
/// <summary>Блок заставки в терминах планировщика: id + длительность слота (кратна сегменту).</summary>
|
||||
public sealed record PlannerBumperTemplate(Guid TemplateId, TimeSpan Duration);
|
||||
|
||||
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
|
||||
public sealed record PlannerInput(
|
||||
Guid ChannelId,
|
||||
@@ -48,13 +51,14 @@ public sealed record PlannerInput(
|
||||
DateTimeOffset StartTime,
|
||||
DateTimeOffset HorizonEnd,
|
||||
PlannerBumperConfig? Bumpers = null,
|
||||
int NextJingleIndex = 0
|
||||
int NextBumperIndex = 0
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Одна запланированная запись (ещё не доменная сущность). Для заставок (<see cref="Kind"/> ==
|
||||
/// <see cref="ScheduleEntryKind.Bumper"/>) <see cref="MediaAssetId"/> пуст — его подставит
|
||||
/// оркестратор после рендера по паре (<see cref="FromShowId"/> → <see cref="ToShowId"/>).
|
||||
/// оркестратор после рендера по паре (<see cref="FromShowId"/> → <see cref="ToShowId"/>) и выбранному
|
||||
/// блоку (<see cref="BumperTemplateId"/>).
|
||||
/// </summary>
|
||||
public sealed record PlannedEntry(
|
||||
Guid MediaAssetId,
|
||||
@@ -64,13 +68,14 @@ public sealed record PlannedEntry(
|
||||
Guid? ShowId,
|
||||
int? EpisodeIndex,
|
||||
Guid? FromShowId = null,
|
||||
Guid? ToShowId = null
|
||||
Guid? ToShowId = null,
|
||||
Guid? BumperTemplateId = null
|
||||
);
|
||||
|
||||
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы).</summary>
|
||||
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow, рекламы, заставок).</summary>
|
||||
public sealed record PlannerResult(
|
||||
IReadOnlyList<PlannedEntry> Entries,
|
||||
IReadOnlyDictionary<Guid, int> NextEpisodeIndexByChannelShow,
|
||||
int NextAdIndex,
|
||||
int NextJingleIndex
|
||||
int NextBumperIndex
|
||||
);
|
||||
|
||||
@@ -136,6 +136,7 @@ public static class DependencyInjection
|
||||
services.AddSingleton<MediaPathResolver>();
|
||||
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
|
||||
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
|
||||
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
|
||||
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
||||
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
|
||||
|
||||
|
||||
@@ -3,67 +3,74 @@ using TeleWave.Application.Common.Interfaces;
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Файловое хранилище шаблонов заставок: сырые фон/музыка под bumpers/{channelId}/{kind}{ext}.
|
||||
/// На канал — не более одного файла каждого вида (при загрузке старый удаляется).
|
||||
/// Файловое хранилище блоков заставок: сырые звук/фон под bumpers/{templateId}/{kind}{ext}.
|
||||
/// На блок — не более одного файла каждого вида (при загрузке старый удаляется).
|
||||
/// </summary>
|
||||
public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemplateStorage
|
||||
{
|
||||
private const string Audio = "audio";
|
||||
private const string Background = "background";
|
||||
private const string Music = "music";
|
||||
|
||||
public Task SaveAudioAsync(
|
||||
Guid templateId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
) => SaveAsync(templateId, Audio, extension, content, cancellationToken);
|
||||
|
||||
public Task SaveBackgroundAsync(
|
||||
Guid channelId,
|
||||
Guid templateId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
) => SaveAsync(channelId, Background, extension, content, cancellationToken);
|
||||
) => SaveAsync(templateId, Background, extension, content, cancellationToken);
|
||||
|
||||
public Task SaveMusicAsync(
|
||||
Guid channelId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
) => SaveAsync(channelId, Music, extension, content, cancellationToken);
|
||||
public void DeleteAudio(Guid templateId) => DeleteKind(templateId, Audio);
|
||||
|
||||
public void DeleteBackground(Guid channelId) => DeleteKind(channelId, Background);
|
||||
public void DeleteBackground(Guid templateId) => DeleteKind(templateId, Background);
|
||||
|
||||
public void DeleteMusic(Guid channelId) => DeleteKind(channelId, Music);
|
||||
public void DeleteTemplate(Guid templateId)
|
||||
{
|
||||
var dir = paths.BumperTemplateDir(templateId);
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
|
||||
public string? BackgroundPath(Guid channelId, string? extension) =>
|
||||
ResolvePath(channelId, Background, extension);
|
||||
public string? AudioPath(Guid templateId, string? extension) =>
|
||||
ResolvePath(templateId, Audio, extension);
|
||||
|
||||
public string? MusicPath(Guid channelId, string? extension) =>
|
||||
ResolvePath(channelId, Music, extension);
|
||||
public string? BackgroundPath(Guid templateId, string? extension) =>
|
||||
ResolvePath(templateId, Background, extension);
|
||||
|
||||
private async Task SaveAsync(
|
||||
Guid channelId,
|
||||
Guid templateId,
|
||||
string kind,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var dir = paths.BumperChannelDir(channelId);
|
||||
var dir = paths.BumperTemplateDir(templateId);
|
||||
Directory.CreateDirectory(dir);
|
||||
RemoveExisting(dir, kind);
|
||||
|
||||
var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension));
|
||||
var path = paths.BumperTemplateFilePath(templateId, kind, NormalizeExtension(extension));
|
||||
await using var fs = File.Create(path);
|
||||
await content.CopyToAsync(fs, cancellationToken);
|
||||
}
|
||||
|
||||
private void DeleteKind(Guid channelId, string kind)
|
||||
private void DeleteKind(Guid templateId, string kind)
|
||||
{
|
||||
var dir = paths.BumperChannelDir(channelId);
|
||||
var dir = paths.BumperTemplateDir(templateId);
|
||||
if (Directory.Exists(dir))
|
||||
RemoveExisting(dir, kind);
|
||||
}
|
||||
|
||||
private string? ResolvePath(Guid channelId, string kind, string? extension)
|
||||
private string? ResolvePath(Guid templateId, string kind, string? extension)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
return null;
|
||||
var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension));
|
||||
var path = paths.BumperTemplateFilePath(templateId, kind, NormalizeExtension(extension));
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Замер длительности аудиофайла через ffprobe (format.duration). Используется при загрузке звука
|
||||
/// блока заставки, чтобы длина заставки шла по длине звука.
|
||||
/// </summary>
|
||||
public sealed class FfprobeAudioProbe(IOptions<MediaOptions> mediaOptions) : IAudioProbe
|
||||
{
|
||||
private readonly MediaOptions _media = mediaOptions.Value;
|
||||
|
||||
public async Task<TimeSpan?> TryGetDurationAsync(
|
||||
string absolutePath,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!File.Exists(absolutePath))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ProcessRunner.RunAsync(
|
||||
_media.FfprobePath,
|
||||
["-v", "quiet", "-print_format", "json", "-show_format", absolutePath],
|
||||
lowPriority: false,
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
return null;
|
||||
|
||||
using var doc = JsonDocument.Parse(result.StdOut);
|
||||
if (
|
||||
doc.RootElement.TryGetProperty("format", out var format)
|
||||
&& format.TryGetProperty("duration", out var durEl)
|
||||
&& double.TryParse(
|
||||
durEl.GetString(),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var seconds
|
||||
)
|
||||
&& seconds > 0
|
||||
)
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
// Не удалось разобрать вывод ffprobe — длину не знаем.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -79,12 +79,12 @@ public sealed class MediaPathResolver
|
||||
}
|
||||
}
|
||||
|
||||
public string BumperChannelDir(Guid channelId) =>
|
||||
EnsureWithinRoot(Path.Combine(BumpersDir, channelId.ToString("N")));
|
||||
public string BumperTemplateDir(Guid templateId) =>
|
||||
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
|
||||
|
||||
/// <summary>Путь к файлу шаблона заставки (kind — «background»/«music», extension — с точкой).</summary>
|
||||
public string BumperFilePath(Guid channelId, string kind, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(BumpersDir, channelId.ToString("N"), kind + extension));
|
||||
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
|
||||
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
|
||||
|
||||
public string OriginalPath(Guid assetId, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
|
||||
|
||||
+859
@@ -0,0 +1,859 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260725074209_BumperTemplates")]
|
||||
partial class BumperTemplates
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("FromShowId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("MediaAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Signature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("ToShowId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FromShowId", "ToShowId", "Signature");
|
||||
|
||||
b.ToTable("BumperAssets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AccentColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<double?>("AudioDurationSeconds")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("AudioExtension")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("BackgroundColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("BackgroundColor2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("BackgroundImageExtension")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Revision")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TextColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId", "Position");
|
||||
|
||||
b.ToTable("BumperTemplate");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AdInsertion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AdsPerBreak")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BumperFont")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BumperMinIntervalMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("BumperNextLabel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BumperNowLabel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("BumperOnlyBetweenDifferentShows")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("BumperSelection")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("BumpersEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("EpochUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("FillerAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<int>("NextAdIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("NextBumperIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("MediaAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId", "Position");
|
||||
|
||||
b.ToTable("ChannelAd");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BlockMode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BlockValue")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("NextEpisodeIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("ShowId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId", "ShowId");
|
||||
|
||||
b.ToTable("ChannelShow");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ProgrammingOverrideId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ShowId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProgrammingOverrideId");
|
||||
|
||||
b.ToTable("OverrideShow");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("EndsAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Mode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("StartsAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc");
|
||||
|
||||
b.ToTable("ProgrammingOverride");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("EndsAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("EpisodeIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("MediaAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ShowId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("StartsAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId", "EndsAtUtc");
|
||||
|
||||
b.HasIndex("ChannelId", "ShowId");
|
||||
|
||||
b.HasIndex("ChannelId", "StartsAtUtc");
|
||||
|
||||
b.ToTable("ScheduleEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("MetadataExternalId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("MetadataProvider")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PosterPath")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<int?>("Year")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Shows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly?>("AirDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("Episode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("MediaAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("Season")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("ShowId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("StillPath")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MediaAssetId");
|
||||
|
||||
b.HasIndex("ShowId", "Position");
|
||||
|
||||
b.ToTable("ShowEpisode");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AudioCodec")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<TimeSpan?>("Duration")
|
||||
.HasColumnType("interval");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OriginalExtension")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("RelativePath")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<int?>("SegmentCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SegmentSeconds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("VideoCodec")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int?>("Width")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("MediaAssets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.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.ChannelAd", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||
.WithMany("Ads")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||
.WithMany("Shows")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
|
||||
.WithMany("Shows")
|
||||
.HasForeignKey("ProgrammingOverrideId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||
.WithMany("Overrides")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Library.Show", null)
|
||||
.WithMany("Episodes")
|
||||
.HasForeignKey("ShowId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
|
||||
{
|
||||
b.Navigation("Ads");
|
||||
|
||||
b.Navigation("BumperTemplates");
|
||||
|
||||
b.Navigation("Overrides");
|
||||
|
||||
b.Navigation("Shows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
|
||||
{
|
||||
b.Navigation("Shows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
|
||||
{
|
||||
b.Navigation("Episodes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BumperTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChannelJingle");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperAccentColor",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperBackgroundColor",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperBackgroundColor2",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperBackgroundExtension",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperDurationSeconds",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperMode",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperMusicExtension",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperTextColor",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NextJingleIndex",
|
||||
table: "Channels",
|
||||
newName: "NextBumperIndex");
|
||||
|
||||
// Ревизия файлов уехала на блоки заставок — старый счётчик не переносим, стратегия по
|
||||
// умолчанию Rotation (0).
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperRevision",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BumperSelection",
|
||||
table: "Channels",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BumperTemplate",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
BackgroundColor = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
BackgroundColor2 = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
AccentColor = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
TextColor = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
BackgroundImageExtension = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
|
||||
AudioExtension = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
|
||||
AudioDurationSeconds = table.Column<double>(type: "double precision", nullable: true),
|
||||
Revision = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BumperTemplate", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BumperTemplate_Channels_ChannelId",
|
||||
column: x => x.ChannelId,
|
||||
principalTable: "Channels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BumperTemplate_ChannelId_Position",
|
||||
table: "BumperTemplate",
|
||||
columns: new[] { "ChannelId", "Position" });
|
||||
|
||||
// Каждому существующему каналу — дефолтный блок заставки (Position 0, без звука/фона).
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO "BumperTemplate"
|
||||
("Id", "ChannelId", "Position", "Name", "BackgroundColor", "BackgroundColor2",
|
||||
"AccentColor", "TextColor", "Revision", "CreatedAt")
|
||||
SELECT gen_random_uuid(), c."Id", 0, 'Заставка 1', '0x0b1020', '0x1e293b',
|
||||
'0x38bdf8', 'white', 0, now()
|
||||
FROM "Channels" c;
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BumperTemplate");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "NextBumperIndex",
|
||||
table: "Channels",
|
||||
newName: "NextJingleIndex");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperSelection",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BumperRevision",
|
||||
table: "Channels",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperAccentColor",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperBackgroundColor",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperBackgroundColor2",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperBackgroundExtension",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BumperDurationSeconds",
|
||||
table: "Channels",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BumperMode",
|
||||
table: "Channels",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperMusicExtension",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperTextColor",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChannelJingle",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChannelJingle", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ChannelJingle_Channels_ChannelId",
|
||||
column: x => x.ChannelId,
|
||||
principalTable: "Channels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChannelJingle_ChannelId_Position",
|
||||
table: "ChannelJingle",
|
||||
columns: new[] { "ChannelId", "Position" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,66 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.ToTable("BumperAssets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AccentColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<double?>("AudioDurationSeconds")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("AudioExtension")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<string>("BackgroundColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("BackgroundColor2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("BackgroundImageExtension")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Revision")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TextColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId", "Position");
|
||||
|
||||
b.ToTable("BumperTemplate");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -199,36 +259,12 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.Property<int>("AdsPerBreak")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("BumperAccentColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BumperBackgroundColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BumperBackgroundColor2")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BumperBackgroundExtension")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("BumperDurationSeconds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BumperFont")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BumperMinIntervalMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BumperMode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("BumperMusicExtension")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BumperNextLabel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -240,13 +276,9 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.Property<bool>("BumperOnlyBetweenDifferentShows")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("BumperRevision")
|
||||
b.Property<int>("BumperSelection")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("BumperTextColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("BumpersEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -270,7 +302,7 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.Property<int>("NextAdIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("NextJingleIndex")
|
||||
b.Property<int>("NextBumperIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
@@ -307,27 +339,6 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.ToTable("ChannelAd");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("MediaAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId", "Position");
|
||||
|
||||
b.ToTable("ChannelJingle");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -765,19 +776,19 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||
.WithMany("Ads")
|
||||
.WithMany("BumperTemplates")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||
.WithMany("Jingles")
|
||||
.WithMany("Ads")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
@@ -823,7 +834,7 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
b.Navigation("Ads");
|
||||
|
||||
b.Navigation("Jingles");
|
||||
b.Navigation("BumperTemplates");
|
||||
|
||||
b.Navigation("Overrides");
|
||||
|
||||
|
||||
+12
-5
@@ -28,11 +28,11 @@ public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
|
||||
builder.Navigation(x => x.Ads).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder
|
||||
.HasMany(x => x.Jingles)
|
||||
.HasMany(x => x.BumperTemplates)
|
||||
.WithOne()
|
||||
.HasForeignKey(j => j.ChannelId)
|
||||
.HasForeignKey(t => t.ChannelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.Navigation(x => x.Jingles).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder
|
||||
.HasMany(x => x.Overrides)
|
||||
@@ -59,11 +59,18 @@ public class ChannelAdConfiguration : IEntityTypeConfiguration<ChannelAd>
|
||||
}
|
||||
}
|
||||
|
||||
public class ChannelJingleConfiguration : IEntityTypeConfiguration<ChannelJingle>
|
||||
public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ChannelJingle> builder)
|
||||
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
|
||||
{
|
||||
builder.HasIndex(x => new { x.ChannelId, x.Position });
|
||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
|
||||
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
|
||||
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
|
||||
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
|
||||
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
|
||||
builder.Property(x => x.BackgroundImageExtension).HasMaxLength(16);
|
||||
builder.Property(x => x.AudioExtension).HasMaxLength(16);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,26 @@ public class SchedulePlannerTests
|
||||
private static Dictionary<Guid, TimeSpan> Durations(params (Guid Id, int Minutes)[] items) =>
|
||||
items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes));
|
||||
|
||||
private static readonly Guid DefaultTemplate = Guid.NewGuid();
|
||||
|
||||
/// <summary>Конфиг заставок с одним дефолтным блоком (8с), если явно не заданы блоки.</summary>
|
||||
private static PlannerBumperConfig Bumper(
|
||||
bool enabled,
|
||||
bool onlyBetweenDifferentShows,
|
||||
TimeSpan minInterval,
|
||||
BumperSelection selection = BumperSelection.Rotation,
|
||||
params PlannerBumperTemplate[] templates
|
||||
) =>
|
||||
new(
|
||||
enabled,
|
||||
onlyBetweenDifferentShows,
|
||||
minInterval,
|
||||
selection,
|
||||
templates.Length == 0
|
||||
? [new PlannerBumperTemplate(DefaultTemplate, TimeSpan.FromSeconds(8))]
|
||||
: templates
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void Count_Block_ProducesConsecutiveEpisodes_BackToBack()
|
||||
{
|
||||
@@ -212,7 +232,7 @@ public class SchedulePlannerTests
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
|
||||
Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
// Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2).
|
||||
@@ -228,6 +248,7 @@ public class SchedulePlannerTests
|
||||
Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор
|
||||
Assert.Equal(a.ShowId, first.FromShowId);
|
||||
Assert.Equal(b.ShowId, first.ToShowId);
|
||||
Assert.Equal(DefaultTemplate, first.BumperTemplateId);
|
||||
Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc);
|
||||
// Встык: программа → заставка → программа.
|
||||
Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc);
|
||||
@@ -246,7 +267,7 @@ public class SchedulePlannerTests
|
||||
horizonEnd: Start.AddMinutes(50)
|
||||
) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
|
||||
Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
@@ -266,7 +287,7 @@ public class SchedulePlannerTests
|
||||
horizonEnd: Start.AddMinutes(50)
|
||||
) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: false, MinInterval: TimeSpan.Zero),
|
||||
Bumpers = Bumper(true, onlyBetweenDifferentShows: false, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
@@ -284,12 +305,7 @@ public class SchedulePlannerTests
|
||||
// Два перехода в горизонте (~на 20-й и ~40-й минуте), но интервал 30 мин пропускает второй.
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(
|
||||
true,
|
||||
TimeSpan.FromSeconds(8),
|
||||
OnlyBetweenDifferentShows: true,
|
||||
MinInterval: TimeSpan.FromMinutes(30)
|
||||
),
|
||||
Bumpers = Bumper(true, onlyBetweenDifferentShows: true, TimeSpan.FromMinutes(30)),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
@@ -298,37 +314,70 @@ public class SchedulePlannerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_StaticMode_UsesJinglesFromPoolInRotation()
|
||||
public void Bumpers_Rotation_CyclesTemplatesAndAdvancesCursor()
|
||||
{
|
||||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||||
Guid j0 = Guid.NewGuid(),
|
||||
j1 = Guid.NewGuid();
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1), (j1, 1));
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
Guid t0 = Guid.NewGuid(),
|
||||
t1 = Guid.NewGuid();
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(
|
||||
Bumpers = Bumper(
|
||||
true,
|
||||
TimeSpan.FromSeconds(8),
|
||||
OnlyBetweenDifferentShows: true,
|
||||
MinInterval: TimeSpan.Zero,
|
||||
Mode: BumperMode.Static,
|
||||
JinglePool: [j0, j1]
|
||||
onlyBetweenDifferentShows: true,
|
||||
TimeSpan.Zero,
|
||||
BumperSelection.Rotation,
|
||||
new PlannerBumperTemplate(t0, TimeSpan.FromSeconds(8)),
|
||||
new PlannerBumperTemplate(t1, TimeSpan.FromSeconds(4))
|
||||
),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||||
Assert.Equal(2, bumpers.Count);
|
||||
Assert.Equal([j0, j1], bumpers.Select(e => e.MediaAssetId)); // ротация пула
|
||||
Assert.All(bumpers, e => Assert.Null(e.FromShowId)); // статик — не по паре шоу
|
||||
Assert.Equal(2, result.NextJingleIndex);
|
||||
Assert.True(bumpers.Count >= 2);
|
||||
// Ротация: первый блок → t0 (8с), второй → t1 (4с). Все — плейсхолдеры по паре шоу.
|
||||
Assert.Equal(t0, bumpers[0].BumperTemplateId);
|
||||
Assert.Equal(TimeSpan.FromSeconds(8), bumpers[0].EndsAtUtc - bumpers[0].StartsAtUtc);
|
||||
Assert.Equal(t1, bumpers[1].BumperTemplateId);
|
||||
Assert.Equal(TimeSpan.FromSeconds(4), bumpers[1].EndsAtUtc - bumpers[1].StartsAtUtc);
|
||||
Assert.All(bumpers, e => Assert.Equal(Guid.Empty, e.MediaAssetId));
|
||||
Assert.Equal(bumpers.Count, result.NextBumperIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_StaticMode_EmptyPool_NoBumpers()
|
||||
public void Bumpers_AlwaysFirst_AlwaysUsesFirstTemplate()
|
||||
{
|
||||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
Guid t0 = Guid.NewGuid(),
|
||||
t1 = Guid.NewGuid();
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
|
||||
{
|
||||
Bumpers = Bumper(
|
||||
true,
|
||||
onlyBetweenDifferentShows: true,
|
||||
TimeSpan.Zero,
|
||||
BumperSelection.AlwaysFirst,
|
||||
new PlannerBumperTemplate(t0, TimeSpan.FromSeconds(8)),
|
||||
new PlannerBumperTemplate(t1, TimeSpan.FromSeconds(4))
|
||||
),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||||
Assert.True(bumpers.Count >= 2);
|
||||
Assert.All(bumpers, e => Assert.Equal(t0, e.BumperTemplateId));
|
||||
Assert.Equal(0, result.NextBumperIndex); // курсор ротации не двигается
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_NoTemplates_NoBumpers()
|
||||
{
|
||||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||||
@@ -338,11 +387,10 @@ public class SchedulePlannerTests
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(
|
||||
true,
|
||||
TimeSpan.FromSeconds(8),
|
||||
OnlyBetweenDifferentShows: true,
|
||||
MinInterval: TimeSpan.Zero,
|
||||
Mode: BumperMode.Static,
|
||||
JinglePool: []
|
||||
Selection: BumperSelection.Rotation,
|
||||
Templates: []
|
||||
),
|
||||
};
|
||||
|
||||
@@ -351,35 +399,6 @@ public class SchedulePlannerTests
|
||||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_BothMode_AlternatesDynamicAndStatic()
|
||||
{
|
||||
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||||
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
|
||||
var j0 = Guid.NewGuid();
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20), (j0, 1));
|
||||
|
||||
// Три перехода: динамика, джингл, динамика.
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(80)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(
|
||||
true,
|
||||
TimeSpan.FromSeconds(8),
|
||||
OnlyBetweenDifferentShows: true,
|
||||
MinInterval: TimeSpan.Zero,
|
||||
Mode: BumperMode.Both,
|
||||
JinglePool: [j0]
|
||||
),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||||
Assert.True(bumpers.Count >= 2);
|
||||
Assert.Equal(Guid.Empty, bumpers[0].MediaAssetId); // первый — динамический (плейсхолдер)
|
||||
Assert.Equal(j0, bumpers[1].MediaAssetId); // второй — статичный джингл
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_Disabled_ProduceNoBumperEntries()
|
||||
{
|
||||
@@ -389,7 +408,7 @@ public class SchedulePlannerTests
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(false, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
|
||||
Bumpers = Bumper(false, onlyBetweenDifferentShows: true, TimeSpan.Zero),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
Reference in New Issue
Block a user