Add TV bumpers functionality: introduce configuration options for bumpers in .env.example and appsettings.json, enhance ChannelEndpoints to manage jingles and bumper assets, and update Channel and ScheduleEntry models to support bumper logic. Implement validation for bumper settings and integrate bumper handling in scheduling logic.
This commit is contained in:
@@ -10,7 +10,7 @@ public static class ResultExtensions
|
||||
public static IResult ToHttpResult<T>(this Result<T> result) =>
|
||||
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
|
||||
|
||||
private static IResult ToProblem(Error error)
|
||||
public static IResult ToProblem(this Error error)
|
||||
{
|
||||
var statusCode = error.Type switch
|
||||
{
|
||||
|
||||
@@ -2,7 +2,10 @@ 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.CreateChannel;
|
||||
using TeleWave.Application.Broadcast.CreateOverride;
|
||||
using TeleWave.Application.Broadcast.DeleteOverride;
|
||||
@@ -11,9 +14,11 @@ 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;
|
||||
|
||||
@@ -49,6 +54,26 @@ public static class ChannelEndpoints
|
||||
.MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapPost("/{id:guid}/jingles", AddJingle)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin
|
||||
.MapDelete("/{id:guid}/jingles/{channelJingleId:guid}", RemoveJingle)
|
||||
.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)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapPost("/{id:guid}/overrides", CreateOverride)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
@@ -109,6 +134,8 @@ public static class ChannelEndpoints
|
||||
body.IsEnabled,
|
||||
body.AdInsertion,
|
||||
body.AdsPerBreak,
|
||||
body.BumpersEnabled,
|
||||
body.Bumper,
|
||||
body.FillerAssetId
|
||||
),
|
||||
cancellationToken
|
||||
@@ -189,6 +216,110 @@ public static class ChannelEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddJingle(
|
||||
Guid id,
|
||||
AddChannelJingleBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddChannelJingleCommand(id, body.MediaAssetId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveJingle(
|
||||
Guid id,
|
||||
Guid channelJingleId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveChannelJingleCommand(id, channelJingleId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadBackground(
|
||||
Guid id,
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IBumperTemplateStorage storage,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (ResolveBumperExtension(fileName, request, BumperFiles.BackgroundExtensions) is not { } ext)
|
||||
return ChannelErrors.InvalidBumperFile.ToProblem();
|
||||
|
||||
await storage.SaveBackgroundAsync(id, ext, request.Body, cancellationToken);
|
||||
|
||||
var result = await sender.Send(new SetBumperBackgroundCommand(id, ext), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
storage.DeleteBackground(id);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClearBackground(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ClearBumperBackgroundCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadMusic(
|
||||
Guid id,
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IBumperTemplateStorage storage,
|
||||
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();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClearMusic(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ClearBumperMusicCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
|
||||
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
|
||||
private static string? ResolveBumperExtension(
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IReadOnlySet<string> allowedExtensions
|
||||
)
|
||||
{
|
||||
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
|
||||
return null;
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
return allowedExtensions.Contains(ext) ? ext : null;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateOverride(
|
||||
Guid id,
|
||||
CreateOverrideBody body,
|
||||
@@ -258,6 +389,8 @@ public sealed record UpdateChannelSettingsBody(
|
||||
bool IsEnabled,
|
||||
AdInsertion AdInsertion,
|
||||
int AdsPerBreak,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsInput Bumper,
|
||||
Guid? FillerAssetId
|
||||
);
|
||||
|
||||
@@ -272,6 +405,42 @@ public sealed record UpdateChannelShowBody(
|
||||
|
||||
public sealed record AddChannelAdBody(Guid MediaAssetId);
|
||||
|
||||
public sealed record AddChannelJingleBody(Guid MediaAssetId);
|
||||
|
||||
/// <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
|
||||
)
|
||||
{
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".bmp",
|
||||
".gif",
|
||||
".mp4",
|
||||
".mov",
|
||||
".mkv",
|
||||
".webm",
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> MusicExtensions = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
{
|
||||
".mp3",
|
||||
".m4a",
|
||||
".aac",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record CreateOverrideBody(
|
||||
OverrideMode Mode,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
|
||||
@@ -34,6 +34,13 @@
|
||||
"NormalizeLoudness": true,
|
||||
"LoudnessTargetLufs": -16
|
||||
},
|
||||
"Bumpers": {
|
||||
"Width": 1280,
|
||||
"Height": 720,
|
||||
"FontFileSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"FontFileSerif": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
|
||||
"TemplateVersion": 1
|
||||
},
|
||||
"Serilog": {
|
||||
"Using": [ "Serilog.Sinks.Console" ],
|
||||
"MinimumLevel": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelJingle;
|
||||
|
||||
public sealed record AddChannelJingleCommand(Guid ChannelId, Guid MediaAssetId)
|
||||
: ICommand<Result<Guid>>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperBackground;
|
||||
|
||||
public sealed record ClearBumperBackgroundCommand(Guid ChannelId) : 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.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
@@ -0,0 +1,7 @@
|
||||
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
@@ -0,0 +1,26 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.BumperMusic;
|
||||
|
||||
public sealed record ClearBumperMusicCommand(Guid ChannelId) : 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.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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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
@@ -0,0 +1,26 @@
|
||||
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,22 @@
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Инфраструктурные настройки рендера ТВ-заставок (общие для всех каналов): разрешение, пути к
|
||||
/// шрифтам, версия шаблона. Оформление и правила (цвета, подписи, длительность, интервал) задаются
|
||||
/// на каждом канале — см. <c>Channel.UpdateBumperSettings</c>.
|
||||
/// </summary>
|
||||
public sealed class BumperOptions
|
||||
{
|
||||
public const string SectionName = "Bumpers";
|
||||
|
||||
public int Width { get; init; } = 1280;
|
||||
public int Height { get; init; } = 720;
|
||||
|
||||
/// <summary>Пути к TTF-шрифтам с кириллицей внутри контейнера (см. Dockerfile, fonts-dejavu-core).</summary>
|
||||
public string FontFileSans { get; init; } = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
|
||||
public string FontFileSerif { get; init; } = "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf";
|
||||
|
||||
/// <summary>Версия шаблона рендера — входит в кэш-ключ заставки; меняй при правке ЛОГИКИ рендера
|
||||
/// (не оформления канала), чтобы пересобрать уже отрендеренные заставки.</summary>
|
||||
public int TemplateVersion { get; init; } = 1;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ 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(
|
||||
@@ -27,6 +29,22 @@ public sealed record ProgrammingOverrideDto(
|
||||
IReadOnlyList<OverrideShowDto> Shows
|
||||
);
|
||||
|
||||
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,
|
||||
bool HasBackground,
|
||||
bool HasMusic
|
||||
);
|
||||
|
||||
public sealed record ChannelDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
@@ -34,8 +52,11 @@ public sealed record ChannelDto(
|
||||
bool IsEnabled,
|
||||
AdInsertion AdInsertion,
|
||||
int AdsPerBreak,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsDto Bumper,
|
||||
Guid? FillerAssetId,
|
||||
IReadOnlyList<ChannelShowDto> Shows,
|
||||
IReadOnlyList<ChannelAdDto> Ads,
|
||||
IReadOnlyList<ChannelJingleDto> Jingles,
|
||||
IReadOnlyList<ProgrammingOverrideDto> Overrides
|
||||
);
|
||||
|
||||
@@ -36,11 +36,26 @@ public static class ChannelErrors
|
||||
"Реклама не найдена в пуле канала."
|
||||
);
|
||||
|
||||
public static readonly Error JingleAlreadyAdded = Error.Conflict(
|
||||
"Channels.JingleAlreadyAdded",
|
||||
"Этот ролик уже в пуле джинглов канала."
|
||||
);
|
||||
|
||||
public static readonly Error JingleNotFound = Error.NotFound(
|
||||
"Channels.JingleNotFound",
|
||||
"Джингл не найден в пуле канала."
|
||||
);
|
||||
|
||||
public static readonly Error AssetNotFound = Error.NotFound(
|
||||
"Channels.AssetNotFound",
|
||||
"Медиа-ассет не найден."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidBumperFile = Error.Validation(
|
||||
"Channels.InvalidBumperFile",
|
||||
"Недопустимый файл заставки (формат или размер)."
|
||||
);
|
||||
|
||||
public static readonly Error OverrideNotFound = Error.NotFound(
|
||||
"Channels.OverrideNotFound",
|
||||
"Override не найден."
|
||||
|
||||
@@ -31,9 +31,12 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
|
||||
var adAssetIds = channel.Ads.Select(a => a.MediaAssetId).ToList();
|
||||
var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId)
|
||||
.Concat(channel.Jingles.Select(j => j.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var assetNames = await dbContext.MediaAssets.AsNoTracking()
|
||||
.Where(a => adAssetIds.Contains(a.Id))
|
||||
.Where(a => poolAssetIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, a.OriginalFileName })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||
|
||||
@@ -62,6 +65,16 @@ 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
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var overrides = channel.Overrides
|
||||
.OrderBy(o => o.StartsAtUtc)
|
||||
.Select(o => new ProgrammingOverrideDto(
|
||||
@@ -83,9 +96,26 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
channel.IsEnabled,
|
||||
channel.AdInsertion,
|
||||
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.FillerAssetId,
|
||||
shows,
|
||||
ads,
|
||||
jingles,
|
||||
overrides
|
||||
)
|
||||
);
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelJingle;
|
||||
|
||||
public sealed record RemoveChannelJingleCommand(Guid ChannelId, Guid ChannelJingleId)
|
||||
: ICommand<Result>;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Media;
|
||||
@@ -11,14 +16,23 @@ namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
|
||||
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
|
||||
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
|
||||
/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) и подставляются
|
||||
/// как обычные ассеты.
|
||||
/// </summary>
|
||||
public sealed class ScheduleGenerator(
|
||||
IAppDbContext dbContext,
|
||||
IRandomSource random,
|
||||
IOptions<SchedulerOptions> options
|
||||
IBumperRenderer bumperRenderer,
|
||||
IBumperTemplateStorage bumperStorage,
|
||||
IOptions<SchedulerOptions> options,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions,
|
||||
ILogger<ScheduleGenerator> logger
|
||||
)
|
||||
{
|
||||
private readonly SchedulerOptions _options = options.Value;
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
|
||||
@@ -34,6 +48,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.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||
@@ -71,22 +86,46 @@ public sealed class ScheduleGenerator(
|
||||
return 0;
|
||||
}
|
||||
|
||||
var showNames = await LoadShowNamesAsync(channel, cancellationToken);
|
||||
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
|
||||
var result = SchedulePlanner.Plan(input, random);
|
||||
|
||||
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана.
|
||||
var bumperAssets = await ResolveBumperAssetsAsync(
|
||||
channel,
|
||||
result.Entries,
|
||||
showNames,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var added = 0;
|
||||
foreach (var entry in result.Entries)
|
||||
{
|
||||
var scheduleEntry = entry.Kind == ScheduleEntryKind.Program
|
||||
? ScheduleEntry.Program(
|
||||
ScheduleEntry? scheduleEntry = entry.Kind switch
|
||||
{
|
||||
ScheduleEntryKind.Program => ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
entry.ShowId!.Value,
|
||||
entry.EpisodeIndex!.Value
|
||||
)
|
||||
: ScheduleEntry.Ad(channel.Id, entry.MediaAssetId, entry.StartsAtUtc, entry.EndsAtUtc);
|
||||
),
|
||||
ScheduleEntryKind.Ad => ScheduleEntry.Ad(
|
||||
channel.Id,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc
|
||||
),
|
||||
ScheduleEntryKind.Bumper => BuildBumperEntry(channel.Id, entry, bumperAssets),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scheduleEntry is null)
|
||||
continue;
|
||||
|
||||
dbContext.ScheduleEntries.Add(scheduleEntry);
|
||||
added++;
|
||||
}
|
||||
|
||||
foreach (var channelShow in channel.Shows)
|
||||
@@ -94,9 +133,233 @@ public sealed class ScheduleGenerator(
|
||||
channelShow.SetNextEpisodeIndex(idx);
|
||||
|
||||
channel.SetNextAdIndex(result.NextAdIndex);
|
||||
channel.SetNextJingleIndex(result.NextJingleIndex);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return result.Entries.Count;
|
||||
return added;
|
||||
}
|
||||
|
||||
private static ScheduleEntry? BuildBumperEntry(
|
||||
Guid channelId,
|
||||
PlannedEntry entry,
|
||||
IReadOnlyDictionary<(Guid, Guid), 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)
|
||||
)
|
||||
// Заставку не удалось отрендерить — пропускаем запись (слот заполнит филлер/следующая
|
||||
// программа). Планировщик уже учёл её длину, поэтому небольшой зазор допустим.
|
||||
return null;
|
||||
|
||||
return ScheduleEntry.Bumper(channelId, assetId, entry.StartsAtUtc, entry.EndsAtUtc, entry.ToShowId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Для каждой уникальной пары «из→в» из запланированных заставок возвращает id готового
|
||||
/// ассета-заставки: из кэша (<see cref="BumperAsset"/>) либо свежесгенерированного.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<(Guid From, Guid To), 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))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (pairs.Count == 0)
|
||||
return result;
|
||||
|
||||
var fromIds = pairs.Select(p => p.From).Distinct().ToList();
|
||||
var toIds = pairs.Select(p => p.To).Distinct().ToList();
|
||||
|
||||
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
|
||||
var cached = await dbContext.BumperAssets.AsNoTracking()
|
||||
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
||||
.Select(b => new
|
||||
{
|
||||
b.FromShowId,
|
||||
b.ToShowId,
|
||||
b.Signature,
|
||||
b.MediaAssetId,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
|
||||
var readyAssetIds = await dbContext.MediaAssets.AsNoTracking()
|
||||
.Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
|
||||
.Select(a => a.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var readySet = readyAssetIds.ToHashSet();
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var fromName = showNames.GetValueOrDefault(pair.From, "…");
|
||||
var toName = showNames.GetValueOrDefault(pair.To, "…");
|
||||
var signature = ComputeSignature(fromName, toName, styleSignature);
|
||||
|
||||
var hit = cached.FirstOrDefault(c =>
|
||||
c.FromShowId == pair.From
|
||||
&& c.ToShowId == pair.To
|
||||
&& c.Signature == signature
|
||||
&& readySet.Contains(c.MediaAssetId)
|
||||
);
|
||||
if (hit is not null)
|
||||
{
|
||||
result[pair] = hit.MediaAssetId;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var assetId = await RenderBumperAsync(
|
||||
channel,
|
||||
pair.From,
|
||||
pair.To,
|
||||
fromName,
|
||||
toName,
|
||||
signature,
|
||||
cancellationToken
|
||||
);
|
||||
result[pair] = assetId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Не удалось отрендерить заставку {From} → {To}",
|
||||
fromName,
|
||||
toName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Guid> RenderBumperAsync(
|
||||
Channel channel,
|
||||
Guid fromShowId,
|
||||
Guid toShowId,
|
||||
string fromName,
|
||||
string toName,
|
||||
string signature,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
||||
var render = await bumperRenderer.RenderAsync(
|
||||
asset.Id,
|
||||
BuildSpec(channel, fromName, toName),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
asset.MarkReady(
|
||||
render.Duration,
|
||||
render.SegmentSeconds,
|
||||
render.SegmentCount,
|
||||
render.Width,
|
||||
render.Height,
|
||||
"h264",
|
||||
"aac",
|
||||
render.RelativePath
|
||||
);
|
||||
|
||||
dbContext.MediaAssets.Add(asset);
|
||||
dbContext.BumperAssets.Add(
|
||||
BumperAsset.Create(fromShowId, toShowId, signature, asset.Id)
|
||||
);
|
||||
return asset.Id;
|
||||
}
|
||||
|
||||
private BumperRenderSpec BuildSpec(Channel channel, string fromName, string toName) =>
|
||||
new(
|
||||
AlignedBumperDuration(channel),
|
||||
_bumper.Width,
|
||||
_bumper.Height,
|
||||
channel.BumperBackgroundColor,
|
||||
channel.BumperBackgroundColor2,
|
||||
channel.BumperAccentColor,
|
||||
channel.BumperTextColor,
|
||||
FontPath(channel.BumperFont),
|
||||
channel.BumperNowLabel,
|
||||
fromName,
|
||||
channel.BumperNextLabel,
|
||||
toName,
|
||||
bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension),
|
||||
bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension)
|
||||
);
|
||||
|
||||
private string FontPath(BumperFont font) =>
|
||||
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
||||
|
||||
/// <summary>Длительность заставки канала, выровненная вверх до кратности сегменту.</summary>
|
||||
private int AlignedBumperDuration(Channel channel)
|
||||
{
|
||||
var requested = Math.Max(_segmentSeconds, channel.BumperDurationSeconds);
|
||||
return (int)(Math.Ceiling((double)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(string fromName, string toName, string styleSignature)
|
||||
{
|
||||
var raw = string.Join('', fromName, toName, styleSignature);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList();
|
||||
if (showIds.Count == 0)
|
||||
return new Dictionary<Guid, string>();
|
||||
|
||||
return await dbContext.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PlannerInput> BuildInputAsync(
|
||||
@@ -122,6 +385,7 @@ 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();
|
||||
|
||||
@@ -162,6 +426,12 @@ public sealed class ScheduleGenerator(
|
||||
.Where(durations.ContainsKey)
|
||||
.ToList();
|
||||
|
||||
var jinglePool = channel.Jingles
|
||||
.OrderBy(j => j.Position)
|
||||
.Select(j => j.MediaAssetId)
|
||||
.Where(durations.ContainsKey)
|
||||
.ToList();
|
||||
|
||||
var overrides = channel.Overrides
|
||||
.Select(o => new PlannerOverride(
|
||||
o.StartsAtUtc,
|
||||
@@ -171,6 +441,15 @@ public sealed class ScheduleGenerator(
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var bumpers = new PlannerBumperConfig(
|
||||
channel.BumpersEnabled,
|
||||
TimeSpan.FromSeconds(AlignedBumperDuration(channel)),
|
||||
channel.BumperOnlyBetweenDifferentShows,
|
||||
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
|
||||
channel.BumperMode,
|
||||
jinglePool
|
||||
);
|
||||
|
||||
return new PlannerInput(
|
||||
channel.Id,
|
||||
channel.AdInsertion,
|
||||
@@ -181,7 +460,9 @@ public sealed class ScheduleGenerator(
|
||||
durations,
|
||||
overrides,
|
||||
startTime,
|
||||
horizonEnd
|
||||
horizonEnd,
|
||||
bumpers,
|
||||
channel.NextJingleIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -10,5 +10,22 @@ public sealed record UpdateChannelSettingsCommand(
|
||||
bool IsEnabled,
|
||||
AdInsertion AdInsertion,
|
||||
int AdsPerBreak,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsInput Bumper,
|
||||
Guid? FillerAssetId
|
||||
) : ICommand<Result>;
|
||||
|
||||
/// <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
|
||||
);
|
||||
|
||||
+14
@@ -32,8 +32,22 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
||||
command.IsEnabled,
|
||||
command.AdInsertion,
|
||||
command.AdsPerBreak,
|
||||
command.BumpersEnabled,
|
||||
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
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -1,13 +1,35 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
public sealed class UpdateChannelSettingsCommandValidator
|
||||
public sealed partial class UpdateChannelSettingsCommandValidator
|
||||
: AbstractValidator<UpdateChannelSettingsCommand>
|
||||
{
|
||||
public 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();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public interface IAppDbContext
|
||||
DbSet<Show> Shows { get; }
|
||||
DbSet<Channel> Channels { get; }
|
||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||
DbSet<BumperAsset> BumperAssets { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Полная спецификация одной заставки для рендера: оформление канала + подписи + названия шоу.
|
||||
/// <see cref="DurationSeconds"/> уже выровнена на длину сегмента (готовит оркестратор), а
|
||||
/// <see cref="FontFile"/> — абсолютный путь к TTF внутри контейнера.
|
||||
/// </summary>
|
||||
public sealed record BumperRenderSpec(
|
||||
int DurationSeconds,
|
||||
int Width,
|
||||
int Height,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
string FontFile,
|
||||
string NowLabel,
|
||||
string NowTitle,
|
||||
string NextLabel,
|
||||
string NextTitle,
|
||||
string? BackgroundFile = null,
|
||||
string? MusicFile = null
|
||||
);
|
||||
|
||||
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
||||
public sealed record BumperRenderResult(
|
||||
TimeSpan Duration,
|
||||
int SegmentSeconds,
|
||||
int SegmentCount,
|
||||
int Width,
|
||||
int Height,
|
||||
string RelativePath
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + текст
|
||||
/// «Сейчас/Далее» + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в
|
||||
/// assets/{assetId} — так же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
|
||||
/// </summary>
|
||||
public interface IBumperRenderer
|
||||
{
|
||||
Task<BumperRenderResult> RenderAsync(
|
||||
Guid assetId,
|
||||
BumperRenderSpec spec,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Хранилище сырых файлов шаблона заставки канала (фон и музыка) под bumpers/{channelId}. В отличие
|
||||
/// от обычных ассетов эти файлы НЕ режутся на HLS — они подаются как входы в рендер заставки.
|
||||
/// </summary>
|
||||
public interface IBumperTemplateStorage
|
||||
{
|
||||
Task SaveBackgroundAsync(
|
||||
Guid channelId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task SaveMusicAsync(
|
||||
Guid channelId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
void DeleteBackground(Guid channelId);
|
||||
void DeleteMusic(Guid channelId);
|
||||
|
||||
/// <summary>Абсолютный путь к загруженному фону или null (нет расширения / файл отсутствует).</summary>
|
||||
string? BackgroundPath(Guid channelId, string? extension);
|
||||
|
||||
/// <summary>Абсолютный путь к загруженной музыке или null.</summary>
|
||||
string? MusicPath(Guid channelId, string? extension);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Media.ListMedia;
|
||||
|
||||
@@ -13,7 +14,9 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var q = dbContext.MediaAssets.AsNoTracking();
|
||||
// Сгенерированные системой ассеты (ТВ-заставки) не показываем в библиотеке медиа.
|
||||
var q = dbContext.MediaAssets.AsNoTracking()
|
||||
.Where(x => x.Source != MediaSource.Generated);
|
||||
|
||||
if (query.Statuses.Count > 0)
|
||||
q = q.Where(x => query.Statuses.Contains(x.Status));
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace TeleWave.Domain.Broadcast;
|
||||
|
||||
/// <summary>
|
||||
/// Кэш отрендеренной ТВ-заставки перехода. Один сгенерированный <see cref="MediaAssetId"/> на
|
||||
/// уникальную комбинацию (<see cref="FromShowId"/> → <see cref="ToShowId"/>) при данной
|
||||
/// <see cref="Signature"/> (хэш названий шоу и версии шаблона). Переиспользуется между днями и
|
||||
/// каналами; при смене названий/шаблона <see cref="Signature"/> меняется и рендерится новый ассет.
|
||||
/// </summary>
|
||||
public class BumperAsset
|
||||
{
|
||||
public Guid Id { get; private set; }
|
||||
public Guid FromShowId { get; private set; }
|
||||
public Guid ToShowId { get; private set; }
|
||||
|
||||
/// <summary>Хэш входных данных рендера (названия «из/в» + версия шаблона).</summary>
|
||||
public string Signature { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Сгенерированный ассет-заставка (нарезан в assets/{id}, статус Ready).</summary>
|
||||
public Guid MediaAssetId { get; private set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
private BumperAsset() { }
|
||||
|
||||
public static BumperAsset Create(
|
||||
Guid fromShowId,
|
||||
Guid toShowId,
|
||||
string signature,
|
||||
Guid mediaAssetId
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FromShowId = fromShowId,
|
||||
ToShowId = toShowId,
|
||||
Signature = signature,
|
||||
MediaAssetId = mediaAssetId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TeleWave.Domain.Broadcast;
|
||||
|
||||
/// <summary>Гарнитура текста на заставке. Маппится на конкретный TTF в конфигурации рендерера.</summary>
|
||||
public enum BumperFont
|
||||
{
|
||||
/// <summary>Гротеск (DejaVu Sans Bold) — по умолчанию.</summary>
|
||||
Sans,
|
||||
|
||||
/// <summary>Антиква (DejaVu Serif Bold).</summary>
|
||||
Serif,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TeleWave.Domain.Broadcast;
|
||||
|
||||
/// <summary>Какие заставки вставлять на переходах.</summary>
|
||||
public enum BumperMode
|
||||
{
|
||||
/// <summary>Только динамические «Сейчас/Далее», отрисованные по оформлению канала.</summary>
|
||||
Dynamic,
|
||||
|
||||
/// <summary>Только готовые ролики-джинглы из пула канала (по кругу).</summary>
|
||||
Static,
|
||||
|
||||
/// <summary>И то, и другое — чередуя на соседних переходах.</summary>
|
||||
Both,
|
||||
}
|
||||
@@ -10,6 +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();
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
@@ -22,6 +23,49 @@ public class Channel
|
||||
public AdInsertion AdInsertion { get; private set; }
|
||||
public int AdsPerBreak { get; private set; }
|
||||
|
||||
/// <summary>Вставлять ли ТВ-заставки на переходах между разными шоу.</summary>
|
||||
public bool BumpersEnabled { get; private set; }
|
||||
|
||||
/// <summary>Какие заставки вставлять: динамические «Сейчас/Далее», статичные джинглы или оба.</summary>
|
||||
public BumperMode BumperMode { get; private set; }
|
||||
|
||||
/// <summary>Расширение загруженного фона (с точкой) или null — тогда синтезируется градиент.</summary>
|
||||
public string? BumperBackgroundExtension { get; private set; }
|
||||
|
||||
/// <summary>Расширение загруженной музыки (с точкой) или null — тогда синтезируется джингл.</summary>
|
||||
public string? BumperMusicExtension { 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;
|
||||
|
||||
/// <summary>Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе).</summary>
|
||||
public int BumperMinIntervalMinutes { get; private set; }
|
||||
|
||||
/// <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 = "ДАЛЕЕ";
|
||||
|
||||
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
||||
public Guid? FillerAssetId { get; private set; }
|
||||
|
||||
@@ -36,6 +80,9 @@ public class Channel
|
||||
public IReadOnlyList<ChannelAd> Ads => _ads;
|
||||
public IReadOnlyList<ProgrammingOverride> Overrides => _overrides;
|
||||
|
||||
/// <summary>Пул джинглов-отбивок; порядок ротации — по <see cref="ChannelJingle.Position"/>.</summary>
|
||||
public IReadOnlyList<ChannelJingle> Jingles => _jingles;
|
||||
|
||||
private Channel() { }
|
||||
|
||||
public static Channel Create(string name, string slug, DateTimeOffset epochUtc) =>
|
||||
@@ -48,6 +95,22 @@ public class Channel
|
||||
EpochUtc = epochUtc,
|
||||
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,
|
||||
BumperFont = BumperFont.Sans,
|
||||
BumperNowLabel = DefaultNowLabel,
|
||||
BumperNextLabel = DefaultNextLabel,
|
||||
BumperMinIntervalMinutes = 0,
|
||||
BumperOnlyBetweenDifferentShows = true,
|
||||
NextAdIndex = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
@@ -57,6 +120,7 @@ public class Channel
|
||||
bool isEnabled,
|
||||
AdInsertion adInsertion,
|
||||
int adsPerBreak,
|
||||
bool bumpersEnabled,
|
||||
Guid? fillerAssetId
|
||||
)
|
||||
{
|
||||
@@ -64,9 +128,90 @@ public class Channel
|
||||
IsEnabled = isEnabled;
|
||||
AdInsertion = adInsertion;
|
||||
AdsPerBreak = adsPerBreak;
|
||||
BumpersEnabled = bumpersEnabled;
|
||||
FillerAssetId = fillerAssetId;
|
||||
}
|
||||
|
||||
/// <summary>Оформление и правила ТВ-заставок канала. Цвета — в нотации ffmpeg (0xRRGGBB или имя).</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
|
||||
)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Отметить загруженный фон (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
|
||||
public void SetBumperBackground(string extension)
|
||||
{
|
||||
BumperBackgroundExtension = extension;
|
||||
BumperRevision++;
|
||||
}
|
||||
|
||||
public void ClearBumperBackground()
|
||||
{
|
||||
if (BumperBackgroundExtension is null)
|
||||
return;
|
||||
BumperBackgroundExtension = null;
|
||||
BumperRevision++;
|
||||
}
|
||||
|
||||
/// <summary>Отметить загруженную музыку (extension — с точкой, нижний регистр). Меняет ревизию.</summary>
|
||||
public void SetBumperMusic(string extension)
|
||||
{
|
||||
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)
|
||||
return false;
|
||||
_jingles.Remove(jingle);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool HasJingle(Guid mediaAssetId) => _jingles.Any(j => j.MediaAssetId == mediaAssetId);
|
||||
|
||||
/// <summary>Планировщик двигает курсор пула джинглов по мере вставки отбивок.</summary>
|
||||
public void SetNextJingleIndex(int index) => NextJingleIndex = index;
|
||||
|
||||
public ChannelShow? FindShow(Guid channelShowId) => _shows.FirstOrDefault(s => s.Id == channelShowId);
|
||||
|
||||
public ChannelShow AddShow(Guid showId, int weight, BlockMode blockMode, int blockValue)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -56,4 +56,23 @@ public class ScheduleEntry
|
||||
StartsAtUtc = startsAtUtc,
|
||||
EndsAtUtc = endsAtUtc,
|
||||
};
|
||||
|
||||
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
|
||||
public static ScheduleEntry Bumper(
|
||||
Guid channelId,
|
||||
Guid mediaAssetId,
|
||||
DateTimeOffset startsAtUtc,
|
||||
DateTimeOffset endsAtUtc,
|
||||
Guid? showId
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChannelId = channelId,
|
||||
MediaAssetId = mediaAssetId,
|
||||
Kind = ScheduleEntryKind.Bumper,
|
||||
StartsAtUtc = startsAtUtc,
|
||||
EndsAtUtc = endsAtUtc,
|
||||
ShowId = showId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@ public enum ScheduleEntryKind
|
||||
|
||||
/// <summary>Рекламная врезка.</summary>
|
||||
Ad,
|
||||
|
||||
/// <summary>ТВ-заставка на переходе между шоу («Сейчас: X · Далее: Y»).</summary>
|
||||
Bumper,
|
||||
}
|
||||
|
||||
@@ -19,14 +19,18 @@ 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 anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0);
|
||||
if (!anyPlayable)
|
||||
return new PlannerResult(entries, nextEpisode, nextAd);
|
||||
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
|
||||
|
||||
var cursor = input.StartTime;
|
||||
var iterations = 0;
|
||||
Guid? prevShowId = null;
|
||||
DateTimeOffset? lastBumperAt = null;
|
||||
var bumperCount = 0;
|
||||
|
||||
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
|
||||
{
|
||||
@@ -35,6 +39,28 @@ public static class SchedulePlanner
|
||||
break;
|
||||
|
||||
var pick = WeightedPick(candidates, random);
|
||||
|
||||
// ТВ-заставка на переходе. Динамическую (Сейчас/Далее) резервируем слотом фикс. длины —
|
||||
// ассет отрендерит оркестратор; статичный джингл берём готовым из пула (реальная длина).
|
||||
if (
|
||||
prevShowId is { } prev
|
||||
&& input.Bumpers is { Enabled: true } bumper
|
||||
&& (!bumper.OnlyBetweenDifferentShows || prev != pick.ShowId)
|
||||
&& (
|
||||
bumper.MinInterval <= TimeSpan.Zero
|
||||
|| lastBumperAt is not { } last
|
||||
|| cursor - last >= bumper.MinInterval
|
||||
)
|
||||
)
|
||||
{
|
||||
var bumperStart = cursor;
|
||||
if (TryPlaceBumper(entries, bumper, bumperCount, prev, pick.ShowId, input, ref nextJingle, ref cursor))
|
||||
{
|
||||
lastBumperAt = bumperStart;
|
||||
bumperCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var blockStart = cursor;
|
||||
|
||||
var block = CollectBlock(pick, nextEpisode, input, cursor);
|
||||
@@ -64,9 +90,80 @@ public static class SchedulePlanner
|
||||
// Защита от зацикливания, если длительности нулевые/отсутствуют — эфир не сдвинулся.
|
||||
if (cursor <= blockStart)
|
||||
break;
|
||||
|
||||
prevShowId = pick.ShowId;
|
||||
}
|
||||
|
||||
return new PlannerResult(entries, nextEpisode, nextAd);
|
||||
return new PlannerResult(entries, nextEpisode, nextAd, nextJingle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ставит одну заставку на переходе по режиму канала. Динамическая — плейсхолдер фикс. длины
|
||||
/// (ассет подставит оркестратор). Статичная — готовый джингл из пула (реальная длина, курсор
|
||||
/// двигается). В режиме Both типы чередуются; при пустом пуле Both уходит в динамику.
|
||||
/// Возвращает true, если заставка добавлена (курсор сдвинут).
|
||||
/// </summary>
|
||||
private static bool TryPlaceBumper(
|
||||
List<PlannedEntry> entries,
|
||||
PlannerBumperConfig bumper,
|
||||
int bumperCount,
|
||||
Guid fromShowId,
|
||||
Guid toShowId,
|
||||
PlannerInput input,
|
||||
ref int nextJingle,
|
||||
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)
|
||||
return false;
|
||||
|
||||
var dynEnd = cursor + bumper.Duration;
|
||||
entries.Add(
|
||||
new PlannedEntry(
|
||||
Guid.Empty,
|
||||
ScheduleEntryKind.Bumper,
|
||||
cursor,
|
||||
dynEnd,
|
||||
toShowId,
|
||||
null,
|
||||
fromShowId,
|
||||
toShowId
|
||||
)
|
||||
);
|
||||
cursor = dynEnd;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<(PlannerShow Show, int Weight)> ResolvePolicy(
|
||||
|
||||
@@ -21,6 +21,20 @@ public sealed record PlannerOverride(
|
||||
|
||||
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
|
||||
|
||||
/// <summary>
|
||||
/// Политика ТВ-заставок на переходах. <see cref="Duration"/> должна быть кратна длине сегмента
|
||||
/// (генератор выравнивает). Планировщик резервирует под заставку слот этой длины, а конкретный
|
||||
/// сгенерированный ассет подставляет уже оркестратор.
|
||||
/// </summary>
|
||||
public sealed record PlannerBumperConfig(
|
||||
bool Enabled,
|
||||
TimeSpan Duration,
|
||||
bool OnlyBetweenDifferentShows,
|
||||
TimeSpan MinInterval,
|
||||
BumperMode Mode = BumperMode.Dynamic,
|
||||
IReadOnlyList<Guid>? JinglePool = null
|
||||
);
|
||||
|
||||
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
|
||||
public sealed record PlannerInput(
|
||||
Guid ChannelId,
|
||||
@@ -32,22 +46,31 @@ public sealed record PlannerInput(
|
||||
IReadOnlyDictionary<Guid, TimeSpan> Durations,
|
||||
IReadOnlyList<PlannerOverride> Overrides,
|
||||
DateTimeOffset StartTime,
|
||||
DateTimeOffset HorizonEnd
|
||||
DateTimeOffset HorizonEnd,
|
||||
PlannerBumperConfig? Bumpers = null,
|
||||
int NextJingleIndex = 0
|
||||
);
|
||||
|
||||
/// <summary>Одна запланированная запись (ещё не доменная сущность).</summary>
|
||||
/// <summary>
|
||||
/// Одна запланированная запись (ещё не доменная сущность). Для заставок (<see cref="Kind"/> ==
|
||||
/// <see cref="ScheduleEntryKind.Bumper"/>) <see cref="MediaAssetId"/> пуст — его подставит
|
||||
/// оркестратор после рендера по паре (<see cref="FromShowId"/> → <see cref="ToShowId"/>).
|
||||
/// </summary>
|
||||
public sealed record PlannedEntry(
|
||||
Guid MediaAssetId,
|
||||
ScheduleEntryKind Kind,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
Guid? ShowId,
|
||||
int? EpisodeIndex
|
||||
int? EpisodeIndex,
|
||||
Guid? FromShowId = null,
|
||||
Guid? ToShowId = null
|
||||
);
|
||||
|
||||
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы).</summary>
|
||||
public sealed record PlannerResult(
|
||||
IReadOnlyList<PlannedEntry> Entries,
|
||||
IReadOnlyDictionary<Guid, int> NextEpisodeIndexByChannelShow,
|
||||
int NextAdIndex
|
||||
int NextAdIndex,
|
||||
int NextJingleIndex
|
||||
);
|
||||
|
||||
@@ -55,6 +55,25 @@ public class MediaAsset
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ассет, сгенерированный системой (ТВ-заставка). Нарезку делает не общий пайплайн обработки, а
|
||||
/// специализированный рендерер, поэтому сразу помечаем <see cref="MarkReady"/> после создания.
|
||||
/// </summary>
|
||||
public static MediaAsset RegisterGenerated(string displayName)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new MediaAsset
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OriginalFileName = displayName,
|
||||
OriginalExtension = string.Empty,
|
||||
Source = MediaSource.Generated,
|
||||
Status = MediaAssetStatus.Pending,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
}
|
||||
|
||||
public void MarkProcessing()
|
||||
{
|
||||
Status = MediaAssetStatus.Processing;
|
||||
|
||||
@@ -8,4 +8,8 @@ public enum MediaSource
|
||||
|
||||
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
|
||||
Inbox,
|
||||
|
||||
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
|
||||
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
|
||||
Generated,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
@@ -102,9 +103,11 @@ public static class DependencyInjection
|
||||
{
|
||||
services.Configure<SchedulerOptions>(configuration.GetSection(SchedulerOptions.SectionName));
|
||||
services.Configure<StreamingOptions>(configuration.GetSection(StreamingOptions.SectionName));
|
||||
services.Configure<BumperOptions>(configuration.GetSection(BumperOptions.SectionName));
|
||||
|
||||
services.AddSingleton<IRandomSource, SystemRandomSource>();
|
||||
services.AddSingleton<StreamTokenService>();
|
||||
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
|
||||
services.AddScoped<ScheduleGenerator>();
|
||||
services.AddHostedService<SchedulingBackgroundService>();
|
||||
}
|
||||
@@ -117,6 +120,7 @@ public static class DependencyInjection
|
||||
|
||||
services.AddSingleton<MediaPathResolver>();
|
||||
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
|
||||
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
|
||||
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
||||
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Файловое хранилище шаблонов заставок: сырые фон/музыка под bumpers/{channelId}/{kind}{ext}.
|
||||
/// На канал — не более одного файла каждого вида (при загрузке старый удаляется).
|
||||
/// </summary>
|
||||
public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemplateStorage
|
||||
{
|
||||
private const string Background = "background";
|
||||
private const string Music = "music";
|
||||
|
||||
public Task SaveBackgroundAsync(
|
||||
Guid channelId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
) => SaveAsync(channelId, Background, extension, content, cancellationToken);
|
||||
|
||||
public Task SaveMusicAsync(
|
||||
Guid channelId,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
) => SaveAsync(channelId, Music, extension, content, cancellationToken);
|
||||
|
||||
public void DeleteBackground(Guid channelId) => DeleteKind(channelId, Background);
|
||||
|
||||
public void DeleteMusic(Guid channelId) => DeleteKind(channelId, Music);
|
||||
|
||||
public string? BackgroundPath(Guid channelId, string? extension) =>
|
||||
ResolvePath(channelId, Background, extension);
|
||||
|
||||
public string? MusicPath(Guid channelId, string? extension) =>
|
||||
ResolvePath(channelId, Music, extension);
|
||||
|
||||
private async Task SaveAsync(
|
||||
Guid channelId,
|
||||
string kind,
|
||||
string extension,
|
||||
Stream content,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var dir = paths.BumperChannelDir(channelId);
|
||||
Directory.CreateDirectory(dir);
|
||||
RemoveExisting(dir, kind);
|
||||
|
||||
var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension));
|
||||
await using var fs = File.Create(path);
|
||||
await content.CopyToAsync(fs, cancellationToken);
|
||||
}
|
||||
|
||||
private void DeleteKind(Guid channelId, string kind)
|
||||
{
|
||||
var dir = paths.BumperChannelDir(channelId);
|
||||
if (Directory.Exists(dir))
|
||||
RemoveExisting(dir, kind);
|
||||
}
|
||||
|
||||
private string? ResolvePath(Guid channelId, string kind, string? extension)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
return null;
|
||||
var path = paths.BumperFilePath(channelId, kind, NormalizeExtension(extension));
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
private static void RemoveExisting(string dir, string kind)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(dir, kind + ".*"))
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
private static string NormalizeExtension(string extension) =>
|
||||
extension.StartsWith('.') ? extension : "." + extension;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Синтезирует ТВ-заставку перехода полностью на ffmpeg (без исходного файла) по
|
||||
/// <see cref="BumperRenderSpec"/>: анимированный градиентный фон + текст «Сейчас/Далее» + короткий
|
||||
/// джингл, и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и
|
||||
/// кратная сегменту, поэтому эфирная математика не отличает заставку от программы.
|
||||
/// </summary>
|
||||
public sealed class FfmpegBumperRenderer(
|
||||
MediaPathResolver paths,
|
||||
IOptions<StorageOptions> storageOptions,
|
||||
IOptions<MediaOptions> mediaOptions
|
||||
) : IBumperRenderer
|
||||
{
|
||||
private readonly StorageOptions _storage = storageOptions.Value;
|
||||
private readonly MediaOptions _media = mediaOptions.Value;
|
||||
|
||||
public async Task<BumperRenderResult> RenderAsync(
|
||||
Guid assetId,
|
||||
BumperRenderSpec spec,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var seg = Math.Max(1, _storage.SegmentSeconds);
|
||||
// Длительность — вверх до кратности сегменту (оркестратор обычно уже выровнял, страхуемся).
|
||||
var target = (int)(Math.Ceiling((double)Math.Max(seg, spec.DurationSeconds) / seg) * seg);
|
||||
|
||||
var assetDir = paths.AssetDir(assetId);
|
||||
if (Directory.Exists(assetDir))
|
||||
Directory.Delete(assetDir, recursive: true);
|
||||
Directory.CreateDirectory(assetDir);
|
||||
|
||||
// Динамический текст (названия шоу) пишем в файлы и читаем через textfile= с expansion=none —
|
||||
// так произвольные символы/кириллица не ломают синтаксис фильтра.
|
||||
var nowFile = Path.Combine(assetDir, "now.txt");
|
||||
var nextFile = Path.Combine(assetDir, "next.txt");
|
||||
await File.WriteAllTextAsync(nowFile, spec.NowTitle, new UTF8Encoding(false), cancellationToken);
|
||||
await File.WriteAllTextAsync(nextFile, spec.NextTitle, new UTF8Encoding(false), cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var args = BuildArgs(assetDir, seg, target, nowFile, nextFile, spec);
|
||||
var result = await ProcessRunner.RunAsync(
|
||||
_media.FfmpegPath,
|
||||
args,
|
||||
lowPriority: true,
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"ffmpeg (заставка) завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}"
|
||||
);
|
||||
|
||||
var playlist = Path.Combine(assetDir, "index.m3u8");
|
||||
if (!File.Exists(playlist))
|
||||
throw new InvalidOperationException("ffmpeg не создал плейлист заставки index.m3u8.");
|
||||
|
||||
var segmentCount = Directory.GetFiles(assetDir, "seg*.ts").Length;
|
||||
if (segmentCount == 0)
|
||||
throw new InvalidOperationException("ffmpeg не создал ни одного сегмента заставки.");
|
||||
|
||||
return new BumperRenderResult(
|
||||
TimeSpan.FromSeconds(target),
|
||||
seg,
|
||||
segmentCount,
|
||||
spec.Width,
|
||||
spec.Height,
|
||||
paths.AssetRelativePath(assetId)
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(nowFile);
|
||||
TryDelete(nextFile);
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> BuildArgs(
|
||||
string assetDir,
|
||||
int seg,
|
||||
int target,
|
||||
string nowFile,
|
||||
string nextFile,
|
||||
BumperRenderSpec spec
|
||||
)
|
||||
{
|
||||
var w = spec.Width;
|
||||
var h = spec.Height;
|
||||
|
||||
var titleSize = Math.Max(24, h / 10);
|
||||
var labelSize = Math.Max(14, h / 22);
|
||||
var gap = (int)(labelSize * 1.4);
|
||||
|
||||
var nowLabelY = (int)(h * 0.22);
|
||||
var nowTitleY = nowLabelY + gap;
|
||||
var nextLabelY = (int)(h * 0.60);
|
||||
var nextTitleY = nextLabelY + gap;
|
||||
|
||||
var font = EscapePath(spec.FontFile);
|
||||
var outStart = Math.Max(0, target - 1);
|
||||
|
||||
// Вход 0 — видеофон: загруженный файл (петля + масштаб/кроп) либо анимированный градиент.
|
||||
var inputs = new List<string>();
|
||||
string videoPrefix;
|
||||
if (!string.IsNullOrEmpty(spec.BackgroundFile))
|
||||
{
|
||||
if (IsImage(spec.BackgroundFile))
|
||||
inputs.AddRange(["-loop", "1"]);
|
||||
else
|
||||
inputs.AddRange(["-stream_loop", "-1"]);
|
||||
inputs.AddRange(["-i", spec.BackgroundFile]);
|
||||
videoPrefix =
|
||||
$"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps=30,format=yuv420p";
|
||||
}
|
||||
else
|
||||
{
|
||||
var gradient =
|
||||
$"gradients=s={w}x{h}:c0={spec.BackgroundColor}:c1={spec.BackgroundColor2}"
|
||||
+ $":x0=0:y0=0:x1={w}:y1={h}:nb_colors=2:seed=42:speed=0.015:r=30:d={Fmt(target)}";
|
||||
inputs.AddRange(["-f", "lavfi", "-i", gradient]);
|
||||
videoPrefix = "[0:v]format=yuv420p";
|
||||
}
|
||||
|
||||
// Вход 1 — звук: загруженная музыка (петля + loudnorm) либо синтезированный джингл.
|
||||
string audioChain;
|
||||
if (!string.IsNullOrEmpty(spec.MusicFile))
|
||||
{
|
||||
inputs.AddRange(["-stream_loop", "-1", "-i", spec.MusicFile]);
|
||||
audioChain =
|
||||
$"[1:a]loudnorm=I={_media.LoudnessTargetLufs.ToString(CultureInfo.InvariantCulture)}:TP=-1.5:LRA=11"
|
||||
+ $",afade=t=in:d=0.5,afade=t=out:st={Fmt(outStart)}:d=1.0[a]";
|
||||
}
|
||||
else
|
||||
{
|
||||
var jingle =
|
||||
$"aevalsrc=exprs='0.14*sin(2*PI*523.25*t)+0.11*sin(2*PI*659.25*t)+0.09*sin(2*PI*783.99*t)'"
|
||||
+ $":s=48000:d={Fmt(target)}";
|
||||
inputs.AddRange(["-f", "lavfi", "-i", jingle]);
|
||||
audioChain =
|
||||
$"[1:a]tremolo=f=4:d=0.4,afade=t=in:d=0.5,afade=t=out:st={Fmt(outStart)}:d=1.0,volume=0.7[a]";
|
||||
}
|
||||
|
||||
var vchain = new StringBuilder(videoPrefix);
|
||||
vchain.Append(',').Append(DrawLabel(font, spec.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2));
|
||||
vchain.Append(',').Append(DrawTitle(font, nowFile, spec.TextColor, titleSize, nowTitleY, 0.3));
|
||||
vchain.Append(',').Append(DrawLabel(font, spec.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0));
|
||||
vchain.Append(',').Append(DrawTitle(font, nextFile, spec.TextColor, titleSize, nextTitleY, 1.1));
|
||||
vchain.Append("[v]");
|
||||
|
||||
var filterComplex = $"{vchain};{audioChain}";
|
||||
|
||||
var args = new List<string> { "-hide_banner", "-nostdin", "-y" };
|
||||
args.AddRange(inputs);
|
||||
args.AddRange(
|
||||
[
|
||||
"-filter_complex", filterComplex,
|
||||
"-map", "[v]",
|
||||
"-map", "[a]",
|
||||
"-threads", _media.TranscodeThreads.ToString(CultureInfo.InvariantCulture),
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "21",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-force_key_frames", $"expr:gte(t,n_forced*{seg.ToString(CultureInfo.InvariantCulture)})",
|
||||
"-sc_threshold", "0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ac", "2",
|
||||
"-ar", "48000",
|
||||
"-t", Fmt(target),
|
||||
"-f", "hls",
|
||||
"-hls_time", seg.ToString(CultureInfo.InvariantCulture),
|
||||
"-hls_playlist_type", "vod",
|
||||
"-hls_list_size", "0",
|
||||
"-hls_segment_filename", Path.Combine(assetDir, "seg%05d.ts"),
|
||||
Path.Combine(assetDir, "index.m3u8"),
|
||||
]);
|
||||
return args;
|
||||
}
|
||||
|
||||
private static readonly string[] ImageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".bmp"];
|
||||
|
||||
private static bool IsImage(string path) =>
|
||||
ImageExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
|
||||
|
||||
private static string DrawTitle(
|
||||
string font,
|
||||
string textFile,
|
||||
string color,
|
||||
int size,
|
||||
int y,
|
||||
double fadeStart
|
||||
) =>
|
||||
$"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none"
|
||||
+ $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}"
|
||||
+ ":shadowcolor=black@0.6:shadowx=2:shadowy=2"
|
||||
+ $":alpha='{FadeExpr(fadeStart)}'";
|
||||
|
||||
private static string DrawLabel(
|
||||
string font,
|
||||
string text,
|
||||
string color,
|
||||
int size,
|
||||
int y,
|
||||
double fadeStart
|
||||
) =>
|
||||
$"drawtext=fontfile={font}:text={EscapeText(text)}:expansion=none"
|
||||
+ $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}"
|
||||
+ ":shadowcolor=black@0.6:shadowx=1:shadowy=1"
|
||||
+ $":alpha='{FadeExpr(fadeStart)}'";
|
||||
|
||||
private static string FadeExpr(double start) =>
|
||||
$"if(lt(t,{Fmt(start)}),0,min(1,(t-{Fmt(start)})/0.5))";
|
||||
|
||||
/// <summary>Экранирование пути для значения опции фильтра (Windows-разделители → прямые слэши,
|
||||
/// двоеточие экранируется). На Linux (контейнере) — фактически no-op.</summary>
|
||||
private static string EscapePath(string path) =>
|
||||
path.Replace('\\', '/').Replace(":", "\\:");
|
||||
|
||||
/// <summary>Экранирование литерального текста подписи внутри значения опции drawtext.</summary>
|
||||
private static string EscapeText(string text) =>
|
||||
text.Replace("\\", "\\\\").Replace(":", "\\:").Replace("'", "\\'");
|
||||
|
||||
private static string Fmt(double value) =>
|
||||
value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Файл-подсказка для drawtext; не критично, если не удалился.
|
||||
}
|
||||
}
|
||||
|
||||
private static string Tail(string text)
|
||||
{
|
||||
text = text.Trim();
|
||||
const int max = 500;
|
||||
return text.Length <= max ? text : text[^max..];
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public sealed class MediaPathResolver
|
||||
UploadsDir = Path.Combine(_root, "uploads");
|
||||
OriginalsDir = Path.Combine(_root, "originals");
|
||||
AssetsDir = Path.Combine(_root, "assets");
|
||||
BumpersDir = Path.Combine(_root, "bumpers");
|
||||
}
|
||||
|
||||
public string InboxDir { get; }
|
||||
@@ -24,14 +25,25 @@ public sealed class MediaPathResolver
|
||||
public string OriginalsDir { get; }
|
||||
public string AssetsDir { get; }
|
||||
|
||||
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
|
||||
public string BumpersDir { get; }
|
||||
|
||||
public void EnsureDirectories()
|
||||
{
|
||||
Directory.CreateDirectory(InboxDir);
|
||||
Directory.CreateDirectory(UploadsDir);
|
||||
Directory.CreateDirectory(OriginalsDir);
|
||||
Directory.CreateDirectory(AssetsDir);
|
||||
Directory.CreateDirectory(BumpersDir);
|
||||
}
|
||||
|
||||
public string BumperChannelDir(Guid channelId) =>
|
||||
EnsureWithinRoot(Path.Combine(BumpersDir, channelId.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));
|
||||
|
||||
public string OriginalPath(Guid assetId, string extension) =>
|
||||
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
|
||||
|
||||
|
||||
+713
@@ -0,0 +1,713 @@
|
||||
// <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("20260724204325_AddBumpers")]
|
||||
partial class AddBumpers
|
||||
{
|
||||
/// <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.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AdInsertion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AdsPerBreak")
|
||||
.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<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>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Shows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("MediaAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("ShowId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
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.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.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("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,54 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBumpers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "BumpersEnabled",
|
||||
table: "Channels",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BumperAssets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FromShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ToShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Signature = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BumperAssets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
|
||||
table: "BumperAssets",
|
||||
columns: new[] { "FromShowId", "ToShowId", "Signature" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BumperAssets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumpersEnabled",
|
||||
table: "Channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+749
@@ -0,0 +1,749 @@
|
||||
// <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("20260724210635_ChannelBumperSettings")]
|
||||
partial class ChannelBumperSettings
|
||||
{
|
||||
/// <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.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AdInsertion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
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<int>("BumperDurationSeconds")
|
||||
.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<string>("BumperTextColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
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<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>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Shows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("MediaAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("ShowId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
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.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.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("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
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ChannelBumperSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperAccentColor",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "0x38bdf8");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperBackgroundColor",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "0x0b1020");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperBackgroundColor2",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "0x1e293b");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BumperDurationSeconds",
|
||||
table: "Channels",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 8);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BumperFont",
|
||||
table: "Channels",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BumperMinIntervalMinutes",
|
||||
table: "Channels",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperNextLabel",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "ДАЛЕЕ");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperNowLabel",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "СЕЙЧАС");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "BumperOnlyBetweenDifferentShows",
|
||||
table: "Channels",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperTextColor",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "white");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperAccentColor",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperBackgroundColor",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperBackgroundColor2",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperDurationSeconds",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperFont",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperMinIntervalMinutes",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperNextLabel",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperNowLabel",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperOnlyBetweenDifferentShows",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperTextColor",
|
||||
table: "Channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+796
@@ -0,0 +1,796 @@
|
||||
// <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("20260724212853_BumperFilesAndJingles")]
|
||||
partial class BumperFilesAndJingles
|
||||
{
|
||||
/// <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.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AdInsertion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
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");
|
||||
|
||||
b.Property<string>("BumperNowLabel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("BumperOnlyBetweenDifferentShows")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("BumperRevision")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("BumperTextColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
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>("NextJingleIndex")
|
||||
.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.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")
|
||||
.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>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Shows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("MediaAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("ShowId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
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.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.ChannelAd", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||
.WithMany("Ads")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||
.WithMany("Jingles")
|
||||
.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("Jingles");
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BumperFilesAndJingles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BumperBackgroundExtension",
|
||||
table: "Channels",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
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<int>(
|
||||
name: "BumperRevision",
|
||||
table: "Channels",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "NextJingleIndex",
|
||||
table: "Channels",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChannelJingle");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperBackgroundExtension",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperMode",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperMusicExtension",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BumperRevision",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NextJingleIndex",
|
||||
table: "Channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,6 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
@@ -160,10 +159,38 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
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.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AdInsertion")
|
||||
@@ -172,6 +199,57 @@ 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");
|
||||
|
||||
b.Property<string>("BumperNowLabel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("BumperOnlyBetweenDifferentShows")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("BumperRevision")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("BumperTextColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("BumpersEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -192,6 +270,9 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.Property<int>("NextAdIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("NextJingleIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
@@ -208,7 +289,6 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
@@ -227,10 +307,30 @@ 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")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BlockMode")
|
||||
@@ -264,7 +364,6 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ProgrammingOverrideId")
|
||||
@@ -286,7 +385,6 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
@@ -311,7 +409,6 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
@@ -349,7 +446,6 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
@@ -375,7 +471,6 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
@@ -402,7 +497,6 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AudioCodec")
|
||||
@@ -628,6 +722,15 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||
.WithMany("Jingles")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
|
||||
{
|
||||
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
|
||||
@@ -668,6 +771,8 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
b.Navigation("Ads");
|
||||
|
||||
b.Navigation("Jingles");
|
||||
|
||||
b.Navigation("Overrides");
|
||||
|
||||
b.Navigation("Shows");
|
||||
|
||||
@@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<Show> Shows => Set<Show>();
|
||||
public DbSet<Channel> Channels => Set<Channel>();
|
||||
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
||||
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class BumperAssetConfiguration : IEntityTypeConfiguration<BumperAsset>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BumperAsset> builder)
|
||||
{
|
||||
builder.Property(x => x.Signature).IsRequired().HasMaxLength(128);
|
||||
|
||||
// Кэш-ключ заставки: одна отрендеренная пара «из→в» при данной сигнатуре оформления.
|
||||
builder.HasIndex(x => new
|
||||
{
|
||||
x.FromShowId,
|
||||
x.ToShowId,
|
||||
x.Signature,
|
||||
});
|
||||
}
|
||||
}
|
||||
+15
@@ -27,6 +27,13 @@ public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.Navigation(x => x.Ads).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder
|
||||
.HasMany(x => x.Jingles)
|
||||
.WithOne()
|
||||
.HasForeignKey(j => j.ChannelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.Navigation(x => x.Jingles).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder
|
||||
.HasMany(x => x.Overrides)
|
||||
.WithOne()
|
||||
@@ -52,6 +59,14 @@ public class ChannelAdConfiguration : IEntityTypeConfiguration<ChannelAd>
|
||||
}
|
||||
}
|
||||
|
||||
public class ChannelJingleConfiguration : IEntityTypeConfiguration<ChannelJingle>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ChannelJingle> builder)
|
||||
{
|
||||
builder.HasIndex(x => new { x.ChannelId, x.Position });
|
||||
}
|
||||
}
|
||||
|
||||
public class ProgrammingOverrideConfiguration : IEntityTypeConfiguration<ProgrammingOverride>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProgrammingOverride> builder)
|
||||
|
||||
@@ -203,6 +203,200 @@ public class SchedulePlannerTests
|
||||
Assert.Equal(b.ShowId, result.Entries[0].ShowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_InsertedOnTransitionBetweenDifferentShows_BackToBack()
|
||||
{
|
||||
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));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
|
||||
};
|
||||
|
||||
// Чередуем выбор: roll 0 → a, roll 1 → b (веса 1/1, total 2).
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
// Перед первым блоком заставки нет; далее по одной на каждый переход.
|
||||
Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind);
|
||||
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
|
||||
Assert.NotEmpty(bumpers);
|
||||
|
||||
var first = result.Entries[1];
|
||||
Assert.Equal(ScheduleEntryKind.Bumper, first.Kind);
|
||||
Assert.Equal(Guid.Empty, first.MediaAssetId); // ассет подставит оркестратор
|
||||
Assert.Equal(a.ShowId, first.FromShowId);
|
||||
Assert.Equal(b.ShowId, first.ToShowId);
|
||||
Assert.Equal(TimeSpan.FromSeconds(8), first.EndsAtUtc - first.StartsAtUtc);
|
||||
// Встык: программа → заставка → программа.
|
||||
Assert.Equal(result.Entries[0].EndsAtUtc, first.StartsAtUtc);
|
||||
Assert.Equal(first.EndsAtUtc, result.Entries[2].StartsAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_SkippedForSameShow_WhenOnlyBetweenDifferentShows()
|
||||
{
|
||||
var cs = Guid.NewGuid();
|
||||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
var input = BaseInput(
|
||||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)],
|
||||
Durations((eps[0], 20), (eps[1], 20)),
|
||||
horizonEnd: Start.AddMinutes(50)
|
||||
) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_InsertedForSameShow_WhenNotRestrictedToDifferent()
|
||||
{
|
||||
var cs = Guid.NewGuid();
|
||||
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
var input = BaseInput(
|
||||
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, eps, 0)],
|
||||
Durations((eps[0], 20), (eps[1], 20)),
|
||||
horizonEnd: Start.AddMinutes(50)
|
||||
) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(true, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: false, MinInterval: TimeSpan.Zero),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
|
||||
|
||||
Assert.Contains(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_MinInterval_SuppressesTooFrequentBumpers()
|
||||
{
|
||||
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));
|
||||
|
||||
// Два перехода в горизонте (~на 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)
|
||||
),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
Assert.Equal(1, result.Entries.Count(e => e.Kind == ScheduleEntryKind.Bumper));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_StaticMode_UsesJinglesFromPoolInRotation()
|
||||
{
|
||||
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 input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(
|
||||
true,
|
||||
TimeSpan.FromSeconds(8),
|
||||
OnlyBetweenDifferentShows: true,
|
||||
MinInterval: TimeSpan.Zero,
|
||||
Mode: BumperMode.Static,
|
||||
JinglePool: [j0, j1]
|
||||
),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bumpers_StaticMode_EmptyPool_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);
|
||||
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(
|
||||
true,
|
||||
TimeSpan.FromSeconds(8),
|
||||
OnlyBetweenDifferentShows: true,
|
||||
MinInterval: TimeSpan.Zero,
|
||||
Mode: BumperMode.Static,
|
||||
JinglePool: []
|
||||
),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
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()
|
||||
{
|
||||
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));
|
||||
|
||||
var input = BaseInput([a, b], durations, Start.AddMinutes(50)) with
|
||||
{
|
||||
Bumpers = new PlannerBumperConfig(false, TimeSpan.FromSeconds(8), OnlyBetweenDifferentShows: true, MinInterval: TimeSpan.Zero),
|
||||
};
|
||||
|
||||
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
|
||||
|
||||
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoPlayableShows_ReturnsEmpty()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user