Normalize line endings to LF via .gitattributes

Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка
работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево,
чтобы форматтеры не переписывали файлы целиком на каждом прогоне.

Коммит чисто механический: изменений содержимого нет, только концы строк.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:37:33 +03:00
co-authored by Claude Opus 5
parent 9d1c6d2fc3
commit 0442056367
109 changed files with 13469 additions and 13459 deletions
+10
View File
@@ -0,0 +1,10 @@
# Концы строк в репозитории — всегда LF. Бэкенд и так лежал в LF, фронтенд — в CRLF; Prettier
# (endOfLine: lf по умолчанию) выровнял его, и этот файл не даёт разъехаться обратно: без него
# при core.autocrlf=true у другого разработчика CRLF вернулись бы в коммит.
* text=auto eol=lf
# Бинарники не трогаем.
*.png binary
*.jpg binary
*.ico binary
*.woff2 binary
@@ -1,247 +1,247 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel; using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel; using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule; using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels; using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.UpdateChannelSettings; using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime; using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Application.Broadcast.UpdateViewerSettings; using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.Trace; using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
/// <summary> /// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки — /// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки /// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
/// (<c>TemplateEndpoints</c>). /// (<c>TemplateEndpoints</c>).
/// </summary> /// </summary>
public static partial class ChannelEndpoints public static partial class ChannelEndpoints
{ {
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin/channels") var admin = app.MapGroup("/api/admin/channels")
.WithTags("Admin.Channels") .WithTags("Admin.Channels")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created); admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>(); admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>(); admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin admin
.MapPut("/{id:guid}/settings", UpdateSettings) .MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin admin
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate) .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate) .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate) .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio) .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio) .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapPut( .MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/background", "/{id:guid}/bumper/templates/{templateId:guid}/background",
SetTemplateBackground SetTemplateBackground
) )
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapDelete( .MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/background", "/{id:guid}/bumper/templates/{templateId:guid}/background",
ClearTemplateBackground ClearTemplateBackground
) )
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview) .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapGet( admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8", "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
PreviewPlaylist PreviewPlaylist
); );
admin.MapGet( admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}", "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
PreviewSegment PreviewSegment
); );
admin admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant) .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin admin
.MapPut( .MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
UpdateBumperVariant UpdateBumperVariant
) )
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapDelete( .MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
RemoveBumperVariant RemoveBumperVariant
) )
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapGet("/{id:guid}/schedule", GetSchedule) .MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>(); .Produces<IReadOnlyList<ScheduleEntryDto>>();
admin admin
.MapPut("/{id:guid}/viewer", UpdateViewerSettings) .MapPut("/{id:guid}/viewer", UpdateViewerSettings)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации. // «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>(); admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
return app; return app;
} }
private static async Task<IResult> UpdateViewerSettings( private static async Task<IResult> UpdateViewerSettings(
Guid id, Guid id,
UpdateViewerSettingsBody body, UpdateViewerSettingsBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateViewerSettingsCommand( new UpdateViewerSettingsCommand(
id, id,
body.LogoImageId, body.LogoImageId,
body.LogoCorner, body.LogoCorner,
body.LogoOpacity, body.LogoOpacity,
body.ShowClock, body.ShowClock,
body.AnalogFilterStrength body.AnalogFilterStrength
), ),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetEntryTrace( private static async Task<IResult> GetEntryTrace(
Guid entryId, Guid entryId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken); var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateChannel( private static async Task<IResult> CreateChannel(
CreateChannelCommand command, CreateChannelCommand command,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.IsSuccess return result.IsSuccess
? Results.Created( ? Results.Created(
$"/api/admin/channels/{result.Value}", $"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value) new CreatedIdResponse(result.Value)
) )
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> ListChannels( private static async Task<IResult> ListChannels(
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new ListChannelsQuery(), cancellationToken); var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> GetChannel( private static async Task<IResult> GetChannel(
Guid id, Guid id,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new GetChannelQuery(id), cancellationToken); var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UpdateTime( private static async Task<IResult> UpdateTime(
Guid id, Guid id,
UpdateChannelTimeBody body, UpdateChannelTimeBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime), new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UpdateSettings( private static async Task<IResult> UpdateSettings(
Guid id, Guid id,
UpdateChannelSettingsBody body, UpdateChannelSettingsBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateChannelSettingsCommand( new UpdateChannelSettingsCommand(
id, id,
body.Name, body.Name,
body.IsEnabled, body.IsEnabled,
body.BumpersEnabled, body.BumpersEnabled,
body.Bumper, body.Bumper,
body.FillerAssetId body.FillerAssetId
), ),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetSchedule( private static async Task<IResult> GetSchedule(
Guid id, Guid id,
DateTimeOffset? from, DateTimeOffset? from,
DateTimeOffset? to, DateTimeOffset? to,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var fromUtc = from ?? DateTimeOffset.UtcNow; var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1); var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send( var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc), new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary> /// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
public sealed record UpdateChannelTimeBody( public sealed record UpdateChannelTimeBody(
int? Number, int? Number,
int UtcOffsetMinutes, int UtcOffsetMinutes,
TimeOnly DayStartTime TimeOnly DayStartTime
); );
public sealed record UpdateChannelSettingsBody( public sealed record UpdateChannelSettingsBody(
string Name, string Name,
bool IsEnabled, bool IsEnabled,
bool BumpersEnabled, bool BumpersEnabled,
BumperSettingsInput Bumper, BumperSettingsInput Bumper,
Guid? FillerAssetId Guid? FillerAssetId
); );
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary> /// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody( public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId, Guid? LogoImageId,
LogoCorner LogoCorner, LogoCorner LogoCorner,
double LogoOpacity, double LogoOpacity,
bool ShowClock, bool ShowClock,
double AnalogFilterStrength double AnalogFilterStrength
); );
@@ -1,216 +1,216 @@
using System.Text; using System.Text;
using LiteCqrs; using LiteCqrs;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Media; using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete; using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia; using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox; using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.Register; using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Stats; using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media; using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints public static class MediaEndpoints
{ {
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin/media") var admin = app.MapGroup("/api/admin/media")
.WithTags("Admin.Media") .WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created); admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>(); admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>(); admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу. // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>(); admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>(); admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer. // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment); admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app; return app;
} }
/// <summary> /// <summary>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName». /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
/// </summary> /// </summary>
private static async Task<IResult> Upload( private static async Task<IResult> Upload(
string fileName, string fileName,
HttpRequest request, HttpRequest request,
IMediaStorage storage, IMediaStorage storage,
IMediaProcessingQueue queue, IMediaProcessingQueue queue,
ISender sender, ISender sender,
UploadLimits limits, UploadLimits limits,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (string.IsNullOrWhiteSpace(fileName)) if (string.IsNullOrWhiteSpace(fileName))
return Results.Problem( return Results.Problem(
title: MediaErrors.EmptyFileName.Code, title: MediaErrors.EmptyFileName.Code,
detail: MediaErrors.EmptyFileName.Message, detail: MediaErrors.EmptyFileName.Message,
statusCode: StatusCodes.Status400BadRequest statusCode: StatusCodes.Status400BadRequest
); );
if (!MediaFormats.IsAllowed(fileName)) if (!MediaFormats.IsAllowed(fileName))
return Results.Problem( return Results.Problem(
title: MediaErrors.UnsupportedFormat.Code, title: MediaErrors.UnsupportedFormat.Code,
detail: MediaErrors.UnsupportedFormat.Message, detail: MediaErrors.UnsupportedFormat.Message,
statusCode: StatusCodes.Status400BadRequest statusCode: StatusCodes.Status400BadRequest
); );
var contentLength = request.ContentLength ?? 0; var contentLength = request.ContentLength ?? 0;
if (contentLength > limits.MaxUploadBytes) if (contentLength > limits.MaxUploadBytes)
return Results.Problem( return Results.Problem(
title: MediaErrors.FileTooLarge.Code, title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message, detail: MediaErrors.FileTooLarge.Message,
statusCode: StatusCodes.Status400BadRequest statusCode: StatusCodes.Status400BadRequest
); );
var free = storage.GetAvailableFreeSpaceBytes(); var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < limits.MinFreeSpaceBytes) if (free - contentLength < limits.MinFreeSpaceBytes)
return Results.Problem( return Results.Problem(
title: MediaErrors.InsufficientStorage.Code, title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message, detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict statusCode: StatusCodes.Status409Conflict
); );
var extension = Path.GetExtension(fileName); var extension = Path.GetExtension(fileName);
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken); var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
var result = await sender.Send( var result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken cancellationToken
); );
if (!result.IsSuccess) if (!result.IsSuccess)
{ {
await storage.DeleteUploadAsync(token, cancellationToken); await storage.DeleteUploadAsync(token, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
queue.Enqueue(result.Value); queue.Enqueue(result.Value);
return Results.Created( return Results.Created(
$"/api/admin/media/{result.Value}", $"/api/admin/media/{result.Value}",
new UploadMediaResponse(result.Value) new UploadMediaResponse(result.Value)
); );
} }
private static async Task<IResult> List( private static async Task<IResult> List(
[AsParameters] ListMediaFilter filter, [AsParameters] ListMediaFilter filter,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ListMediaAssetsQuery( new ListMediaAssetsQuery(
filter.Page is > 0 ? filter.Page.Value : 1, filter.Page is > 0 ? filter.Page.Value : 1,
filter.PageSize is > 0 ? filter.PageSize.Value : 20, filter.PageSize is > 0 ? filter.PageSize.Value : 20,
filter.Status ?? [], filter.Status ?? [],
filter.Search, filter.Search,
filter.Sort, filter.Sort,
filter.Desc ?? false filter.Desc ?? false
), ),
cancellationToken cancellationToken
); );
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
{ {
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken); var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> Delete( private static async Task<IResult> Delete(
Guid id, Guid id,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken); var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ListManual( private static async Task<IResult> ListManual(
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken); var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> ImportManual( private static async Task<IResult> ImportManual(
ImportManualInboxBody body, ImportManualInboxBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ImportManualInboxCommand(body.Items, body.ShowId), new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary> /// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths) private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{ {
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath) if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
return Results.NotFound(); return Results.NotFound();
var baseUrl = $"/api/admin/media/{id}/preview/"; var baseUrl = $"/api/admin/media/{id}/preview/";
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath)) foreach (var line in File.ReadLines(indexPath))
{ {
var trimmed = line.Trim(); var trimmed = line.Trim();
if (trimmed.Length == 0) if (trimmed.Length == 0)
continue; continue;
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL. // Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed)) sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n'); .Append('\n');
} }
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
} }
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths) private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
{ {
if (!SegmentFiles.IsSegmentName(file)) if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound(); return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path) if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
return Results.NotFound(); return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true); return Results.File(path, "video/mp2t", enableRangeProcessing: true);
} }
} }
/// <summary> /// <summary>
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query /// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые /// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при /// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию. /// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
/// </summary> /// </summary>
public sealed record ListMediaFilter( public sealed record ListMediaFilter(
int? Page, int? Page,
int? PageSize, int? PageSize,
MediaAssetStatus[]? Status, MediaAssetStatus[]? Status,
string? Search, string? Search,
string? Sort, string? Sort,
bool? Desc bool? Desc
); );
public sealed record UploadMediaResponse(Guid Id); public sealed record UploadMediaResponse(Guid Id);
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId); public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
@@ -1,55 +1,55 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Settings; using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings; using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings; using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints public static class SettingsEndpoints
{ {
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin/settings") var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings") .WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>(); admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent); admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> GetSettings( private static async Task<IResult> GetSettings(
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken); var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings); return Results.Ok(settings);
} }
private static async Task<IResult> UpdateSettings( private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body, UpdateSiteSettingsBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateSiteSettingsCommand( new UpdateSiteSettingsCommand(
body.RegistrationEnabled, body.RegistrationEnabled,
body.PreferredAudioLanguages ?? "", body.PreferredAudioLanguages ?? "",
body.ChannelNumbersEnabled body.ChannelNumbersEnabled
), ),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record UpdateSiteSettingsBody( public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled, bool RegistrationEnabled,
string? PreferredAudioLanguages, string? PreferredAudioLanguages,
bool ChannelNumbersEnabled bool ChannelNumbersEnabled
); );
@@ -1,186 +1,186 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Library; using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode; using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.CreateShow; using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow; using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows; using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode; using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow; using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience; using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowGenres; using TeleWave.Application.Library.SetShowGenres;
using TeleWave.Application.Library.SetShowOriginalName; using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints public static class ShowEndpoints
{ {
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin/shows") var admin = app.MapGroup("/api/admin/shows")
.WithTags("Admin.Shows") .WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created); admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>(); admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>(); admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
admin admin
.MapPut("/{id:guid}/original-name", SetOriginalName) .MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
admin admin
.MapPost("/{id:guid}/episodes", AddEpisode) .MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode) .MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> CreateShow( private static async Task<IResult> CreateShow(
CreateShowCommand command, CreateShowCommand command,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.IsSuccess return result.IsSuccess
? Results.Created( ? Results.Created(
$"/api/admin/shows/{result.Value}", $"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value) new CreatedIdResponse(result.Value)
) )
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> ListShows( private static async Task<IResult> ListShows(
ISender sender, ISender sender,
CancellationToken cancellationToken, CancellationToken cancellationToken,
Guid? genreId = null, Guid? genreId = null,
bool interstitials = false bool interstitials = false
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ListShowsQuery(genreId, interstitials), new ListShowsQuery(genreId, interstitials),
cancellationToken cancellationToken
); );
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> GetShow( private static async Task<IResult> GetShow(
Guid id, Guid id,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new GetShowQuery(id), cancellationToken); var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Rename( private static async Task<IResult> Rename(
Guid id, Guid id,
RenameShowBody body, RenameShowBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken); var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> SetOriginalName( private static async Task<IResult> SetOriginalName(
Guid id, Guid id,
SetShowOriginalNameBody body, SetShowOriginalNameBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new SetShowOriginalNameCommand(id, body.OriginalName), new SetShowOriginalNameCommand(id, body.OriginalName),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> SetAudience( private static async Task<IResult> SetAudience(
Guid id, Guid id,
SetShowAudienceBody body, SetShowAudienceBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new SetShowAudienceCommand(id, body.Audience), new SetShowAudienceCommand(id, body.Audience),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> SetGenres( private static async Task<IResult> SetGenres(
Guid id, Guid id,
SetShowGenresBody body, SetShowGenresBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId), new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteShow( private static async Task<IResult> DeleteShow(
Guid id, Guid id,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> AddEpisode( private static async Task<IResult> AddEpisode(
Guid id, Guid id,
AddEpisodeBody body, AddEpisodeBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId), new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken cancellationToken
); );
return result.IsSuccess return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value)) ? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> RemoveEpisode( private static async Task<IResult> RemoveEpisode(
Guid id, Guid id,
Guid episodeId, Guid episodeId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken); var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record AddEpisodeBody(Guid MediaAssetId); public sealed record AddEpisodeBody(Guid MediaAssetId);
public sealed record RenameShowBody(string Name); public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName); public sealed record SetShowOriginalNameBody(string? OriginalName);
/// <summary>Рейтинг шоу; null — снять проставленный.</summary> /// <summary>Рейтинг шоу; null — снять проставленный.</summary>
public sealed record SetShowAudienceBody(ShowAudience? Audience); public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId); public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
@@ -1,191 +1,191 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using LiteCqrs; using LiteCqrs;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist; using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg; using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels; using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media; using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming; using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints public static class StreamingEndpoints
{ {
private const string StreamCookieName = "tw_stream"; private const string StreamCookieName = "tw_stream";
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{ {
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie. // Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization(); var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>(); channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8). // Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>(); channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent); channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg); channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js. // Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming"); app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming"); app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app; return app;
} }
private static async Task<IResult> ListChannels( private static async Task<IResult> ListChannels(
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken); var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result); return Results.Ok(result);
} }
private static async Task<IResult> ViewerFeatures( private static async Task<IResult> ViewerFeatures(
ISiteSettings siteSettings, ISiteSettings siteSettings,
CancellationToken cancellationToken CancellationToken cancellationToken
) => ) =>
Results.Ok( Results.Ok(
new ViewerFeaturesDto( new ViewerFeaturesDto(
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken) await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
) )
); );
/// <summary> /// <summary>
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными /// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на /// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
/// конкретную ленту, поэтому в сигнатуре slug не нужен. /// конкретную ленту, поэтому в сигнатуре slug не нужен.
/// </summary> /// </summary>
private static IResult Watch( private static IResult Watch(
ICurrentUser currentUser, ICurrentUser currentUser,
StreamTokenService tokens, StreamTokenService tokens,
HttpRequest request, HttpRequest request,
HttpResponse response, HttpResponse response,
IHostEnvironment env IHostEnvironment env
) )
{ {
if (currentUser.UserId is not { } userId) if (currentUser.UserId is not { } userId)
return Results.Unauthorized(); return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId); var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append( response.Cookies.Append(
StreamCookieName, StreamCookieName,
token, token,
new CookieOptions new CookieOptions
{ {
HttpOnly = true, HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен // Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints. // при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps, Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict, SameSite = SameSiteMode.Strict,
Path = "/api", Path = "/api",
Expires = expiresAt, Expires = expiresAt,
} }
); );
return Results.NoContent(); return Results.NoContent();
} }
private static async Task<IResult> Epg( private static async Task<IResult> Epg(
string slug, string slug,
DateTimeOffset? from, DateTimeOffset? from,
DateTimeOffset? to, DateTimeOffset? to,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var fromUtc = from ?? DateTimeOffset.UtcNow; var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12); var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send( var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc), new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> LivePlaylist( private static async Task<IResult> LivePlaylist(
string slug, string slug,
HttpRequest request, HttpRequest request,
HttpResponse response, HttpResponse response,
StreamTokenService tokens, StreamTokenService tokens,
IIdentityService identity, IIdentityService identity,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить, // Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу, // что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто). // не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId) if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
return Results.Unauthorized(); return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken); var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked) if (profile is null || profile.IsBlocked)
return Results.Unauthorized(); return Results.Unauthorized();
var result = await sender.Send( var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow), new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken cancellationToken
); );
if (!result.IsSuccess) if (!result.IsSuccess)
return result.ToHttpResult(); return result.ToHttpResult();
if (result.Value.Segments.Count == 0) if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache"; response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl"); return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
} }
private static IResult Segment( private static IResult Segment(
Guid assetId, Guid assetId,
string file, string file,
HttpRequest request, HttpRequest request,
HttpResponse response, HttpResponse response,
StreamTokenService tokens, StreamTokenService tokens,
MediaPathResolver paths MediaPathResolver paths
) )
{ {
if (tokens.Validate(request.Cookies[StreamCookieName]) is null) if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized(); return Results.Unauthorized();
if (!SegmentFiles.IsSegmentName(file)) if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound(); return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path) if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
return Results.NotFound(); return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable"; response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true); return Results.File(path, "video/mp2t", enableRangeProcessing: true);
} }
private static string Render(LivePlaylistDto playlist) private static string Render(LivePlaylistDto playlist)
{ {
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture); var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append("#EXTM3U\n"); sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n"); sb.Append("#EXT-X-VERSION:3\n");
sb.Append( sb.Append(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n" $"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
); );
sb.Append( sb.Append(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n" $"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
); );
foreach (var segment in playlist.Segments) foreach (var segment in playlist.Segments)
{ {
if (segment.Discontinuity) if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n"); sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n"); sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append( sb.Append(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n" $"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
); );
} }
return sb.ToString(); return sb.ToString();
} }
} }
/// <summary>Опции зрительской части, включённые глобально.</summary> /// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled); public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
@@ -1,296 +1,296 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate; using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Diff; using TeleWave.Application.Programming.Planning.Diff;
using TeleWave.Application.Programming.Planning.Preview; using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate; using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot; using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.CreateTemplate; using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.DeleteSlot; using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.GetTemplate; using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers; using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.UpdateSlot; using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate; using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
/// <summary> /// <summary>
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон /// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
/// изменённым, а хвост пересобирается отдельной командой применения. /// изменённым, а хвост пересобирается отдельной командой применения.
/// </summary> /// </summary>
public static class TemplateEndpoints public static class TemplateEndpoints
{ {
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin") var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Templates") .WithTags("Admin.Templates")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin admin
.MapGet("/channels/{channelId:guid}/template", GetTemplate) .MapGet("/channels/{channelId:guid}/template", GetTemplate)
.Produces<ScheduleTemplateDto>(); .Produces<ScheduleTemplateDto>();
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации). // Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
admin admin
.MapPost("/channels/{channelId:guid}/template", CreateTemplate) .MapPost("/channels/{channelId:guid}/template", CreateTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin admin
.MapPut("/templates/{templateId:guid}", UpdateTemplate) .MapPut("/templates/{templateId:guid}", UpdateTemplate)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает. // Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
admin admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate) .MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>(); .Produces<ApplyResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров. // Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate) .MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>(); .Produces<SchedulePreviewDto>();
// Проверки по правилам — только по шаблону, без прогона генератора. // Проверки по правилам — только по шаблону, без прогона генератора.
admin admin
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate) .MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
.Produces<IReadOnlyList<TemplateIssueDto>>(); .Produces<IReadOnlyList<TemplateIssueDto>>();
// Что изменится в эфире, если применить прямо сейчас. // Что изменится в эфире, если применить прямо сейчас.
admin admin
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate) .MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
.Produces<ScheduleDiffDto>(); .Produces<ScheduleDiffDto>();
admin admin
.MapPost( .MapPost(
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}", "/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
CopyTemplate CopyTemplate
) )
.Produces<CopyTemplateResultDto>(); .Produces<CopyTemplateResultDto>();
admin admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer) .MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin admin
.MapPut("/layers/{layerId:guid}", UpdateLayer) .MapPut("/layers/{layerId:guid}", UpdateLayer)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapDelete("/layers/{layerId:guid}", DeleteLayer) .MapDelete("/layers/{layerId:guid}", DeleteLayer)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapPost("/layers/{layerId:guid}/slots", CreateSlot) .MapPost("/layers/{layerId:guid}/slots", CreateSlot)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent); admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
admin admin
.MapDelete("/slots/{slotId:guid}", DeleteSlot) .MapDelete("/slots/{slotId:guid}", DeleteSlot)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> GetTemplate( private static async Task<IResult> GetTemplate(
Guid channelId, Guid channelId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken); var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateTemplate( private static async Task<IResult> CreateTemplate(
Guid channelId, Guid channelId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new CreateChannelTemplateCommand(channelId), new CreateChannelTemplateCommand(channelId),
cancellationToken cancellationToken
); );
return result.IsSuccess return result.IsSuccess
? Results.Created( ? Results.Created(
$"/api/admin/channels/{channelId}/template", $"/api/admin/channels/{channelId}/template",
new CreatedIdResponse(result.Value) new CreatedIdResponse(result.Value)
) )
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> ApplyTemplate( private static async Task<IResult> ApplyTemplate(
Guid channelId, Guid channelId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new ApplyChannelTemplateCommand(channelId), new ApplyChannelTemplateCommand(channelId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> PreviewTemplate( private static async Task<IResult> PreviewTemplate(
Guid channelId, Guid channelId,
ISender sender, ISender sender,
CancellationToken cancellationToken, CancellationToken cancellationToken,
int days = 1 int days = 1
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new PreviewScheduleQuery(channelId, days), new PreviewScheduleQuery(channelId, days),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ValidateTemplate( private static async Task<IResult> ValidateTemplate(
Guid channelId, Guid channelId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken); var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DiffTemplate( private static async Task<IResult> DiffTemplate(
Guid channelId, Guid channelId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken); var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CopyTemplate( private static async Task<IResult> CopyTemplate(
Guid channelId, Guid channelId,
Guid targetChannelId, Guid targetChannelId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new CopyTemplateCommand(channelId, targetChannelId), new CopyTemplateCommand(channelId, targetChannelId),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UpdateTemplate( private static async Task<IResult> UpdateTemplate(
Guid templateId, Guid templateId,
UpdateTemplateBody body, UpdateTemplateBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateTemplateCommand( new UpdateTemplateCommand(
templateId, templateId,
body.Name, body.Name,
body.FallbackGroupId, body.FallbackGroupId,
body.DefaultJunctionId, body.DefaultJunctionId,
body.Rules body.Rules
), ),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateLayer( private static async Task<IResult> CreateLayer(
Guid templateId, Guid templateId,
CreateLayerBody body, CreateLayerBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new CreateLayerCommand(templateId, body.Name, body.Priority), new CreateLayerCommand(templateId, body.Name, body.Priority),
cancellationToken cancellationToken
); );
return result.IsSuccess return result.IsSuccess
? Results.Created( ? Results.Created(
$"/api/admin/layers/{result.Value}", $"/api/admin/layers/{result.Value}",
new CreatedIdResponse(result.Value) new CreatedIdResponse(result.Value)
) )
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> UpdateLayer( private static async Task<IResult> UpdateLayer(
Guid layerId, Guid layerId,
UpdateLayerBody body, UpdateLayerBody body,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send( var result = await sender.Send(
new UpdateLayerCommand( new UpdateLayerCommand(
layerId, layerId,
body.Name, body.Name,
body.Priority, body.Priority,
body.Applicability, body.Applicability,
body.IsEnabled body.IsEnabled
), ),
cancellationToken cancellationToken
); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteLayer( private static async Task<IResult> DeleteLayer(
Guid layerId, Guid layerId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken); var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateSlot( private static async Task<IResult> CreateSlot(
Guid layerId, Guid layerId,
SlotInput input, SlotInput input,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken); var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
return result.IsSuccess return result.IsSuccess
? Results.Created( ? Results.Created(
$"/api/admin/slots/{result.Value}", $"/api/admin/slots/{result.Value}",
new CreatedIdResponse(result.Value) new CreatedIdResponse(result.Value)
) )
: result.ToHttpResult(); : result.ToHttpResult();
} }
private static async Task<IResult> UpdateSlot( private static async Task<IResult> UpdateSlot(
Guid slotId, Guid slotId,
SlotInput input, SlotInput input,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken); var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteSlot( private static async Task<IResult> DeleteSlot(
Guid slotId, Guid slotId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken); var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record UpdateTemplateBody( public sealed record UpdateTemplateBody(
string Name, string Name,
Guid? FallbackGroupId, Guid? FallbackGroupId,
Guid? DefaultJunctionId, Guid? DefaultJunctionId,
PlanningRules? Rules PlanningRules? Rules
); );
public sealed record CreateLayerBody(string Name, int Priority); public sealed record CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody( public sealed record UpdateLayerBody(
string Name, string Name,
int Priority, int Priority,
LayerApplicability? Applicability, LayerApplicability? Applicability,
bool IsEnabled bool IsEnabled
); );
+148 -148
View File
@@ -1,148 +1,148 @@
using System.Net; using System.Net;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog; using Serilog;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Api.Endpoints; using TeleWave.Api.Endpoints;
using TeleWave.Application; using TeleWave.Application;
using TeleWave.Infrastructure; using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence; using TeleWave.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки // Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints. // (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options => builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize = options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024 builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
); );
// Структурное логирование (Serilog), конфигурация из appsettings/env. // Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog( builder.Services.AddSerilog(
(services, configuration) => (services, configuration) =>
configuration configuration
.ReadFrom.Configuration(builder.Configuration) .ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services) .ReadFrom.Services(services)
.Enrich.FromLogContext() .Enrich.FromLogContext()
); );
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно // За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя // перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback, // прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example). // а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach ( foreach (
var proxy in builder var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies") .Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>() .Get<string[]>()
?? [] ?? []
) )
options.KnownProxies.Add(IPAddress.Parse(proxy)); options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach ( foreach (
var network in builder var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks") .Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>() .Get<string[]>()
?? [] ?? []
) )
{ {
var parts = network.Split('/'); var parts = network.Split('/');
options.KnownIPNetworks.Add( options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])) new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
); );
} }
}); });
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<UploadLimits>(); builder.Services.AddSingleton<UploadLimits>();
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20); var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый // Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех. // непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy( options.AddPolicy(
RateLimiting.AuthPolicy, RateLimiting.AuthPolicy,
httpContext => httpContext =>
RateLimitPartition.GetFixedWindowLimiter( RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions _ => new FixedWindowRateLimiterOptions
{ {
PermitLimit = authPermitLimit, PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1), Window = TimeSpan.FromMinutes(1),
QueueLimit = 0, QueueLimit = 0,
} }
) )
); );
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
}); });
// Энумы сериализуются строками, не числами — самодокументируемый JSON. // Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()) options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
); );
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>(); builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build(); var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync(); await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync(); await app.Services.SeedDataAsync();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене // Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
// не раскрывать полную карту эндпоинтов без необходимости. // не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false)) if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{ {
app.MapOpenApi(); app.MapOpenApi();
app.MapScalarApiReference(); app.MapScalarApiReference();
} }
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");
app.MapAuthEndpoints(); app.MapAuthEndpoints();
app.MapRoleEndpoints(); app.MapRoleEndpoints();
app.MapAdminUserEndpoints(); app.MapAdminUserEndpoints();
app.MapMediaEndpoints(); app.MapMediaEndpoints();
app.MapShowEndpoints(); app.MapShowEndpoints();
app.MapGenreEndpoints(); app.MapGenreEndpoints();
app.MapInterstitialEndpoints(); app.MapInterstitialEndpoints();
app.MapCollectionEndpoints(); app.MapCollectionEndpoints();
app.MapGroupEndpoints(); app.MapGroupEndpoints();
app.MapTemplateEndpoints(); app.MapTemplateEndpoints();
app.MapJunctionEndpoints(); app.MapJunctionEndpoints();
app.MapChannelEndpoints(); app.MapChannelEndpoints();
app.MapStreamingEndpoints(); app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints(); app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapMetadataEndpoints(); app.MapMetadataEndpoints();
app.MapImageEndpoints(); app.MapImageEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела // Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним. // сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
await app.RunAsync(); await app.RunAsync();
@@ -1,126 +1,126 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers; namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RenderBumperPreviewCommandHandler( public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IBumperRenderer renderer, IBumperRenderer renderer,
IBumperTemplateStorage storage, IBumperTemplateStorage storage,
IImageStore imageStore, IImageStore imageStore,
IOptions<BumperOptions> bumperOptions, IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions IOptions<StreamingOptions> streamingOptions
) : ICommandHandler<RenderBumperPreviewCommand, Result> ) : ICommandHandler<RenderBumperPreviewCommand, Result>
{ {
private readonly BumperOptions _bumper = bumperOptions.Value; private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary> /// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8; private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> Handle( public async Task<Result> Handle(
RenderBumperPreviewCommand query, RenderBumperPreviewCommand query,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext var channel = await dbContext
.Channels.AsNoTracking() .Channels.AsNoTracking()
.Include(c => c.BumperTemplates) .Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants) .ThenInclude(t => t.Variants)
.AsSplitQuery() .AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null) if (channel is null)
return Result.Failure(ChannelErrors.NotFound); return Result.Failure(ChannelErrors.NotFound);
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId); var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
if (template is null) if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound); return Result.Failure(ChannelErrors.BumperTemplateNotFound);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken); var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
var seconds = template.AudioDurationSeconds is { } d and > 0 var seconds = template.AudioDurationSeconds is { } d and > 0
? d ? d
: DefaultBumperDurationSeconds; : DefaultBumperDurationSeconds;
var aligned = (int)( var aligned = (int)(
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
); );
var audioPath = storage.AudioPath(template.Id, template.AudioExtension); var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем. // Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
var inputs = new BumperSpecInputs( var inputs = new BumperSpecInputs(
fromName, fromName,
toName, toName,
audioPath, audioPath,
PosterAbsolutePath: null, PosterAbsolutePath: null,
backgroundPath backgroundPath
); );
// Рендерим каждый подблок в свой ассет-превью (id по подблоку). // Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position)) foreach (var variant in template.Variants.OrderBy(v => v.Position))
{ {
var spec = BumperSpecFactory.Build( var spec = BumperSpecFactory.Build(
_bumper, _bumper,
channel.BumperFont, channel.BumperFont,
template, template,
variant, variant,
aligned, aligned,
inputs inputs
); );
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
} }
return Result.Success(); return Result.Success();
} }
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary> /// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
private async Task<string?> ResolveBackgroundPathAsync( private async Task<string?> ResolveBackgroundPathAsync(
BumperTemplate template, BumperTemplate template,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (template.BackgroundImageId is not { } imageId) if (template.BackgroundImageId is not { } imageId)
return null; return null;
var extension = await dbContext var extension = await dbContext
.Images.AsNoTracking() .Images.AsNoTracking()
.Where(i => i.Id == imageId) .Where(i => i.Id == imageId)
.Select(i => i.FileExtension) .Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
return extension is null ? null : imageStore.ResolvePath(imageId, extension); return extension is null ? null : imageStore.ResolvePath(imageId, extension);
} }
/// <summary> /// <summary>
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала: /// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки. /// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
/// </summary> /// </summary>
private async Task<(string From, string To)> SampleNamesAsync( private async Task<(string From, string To)> SampleNamesAsync(
Channel channel, Channel channel,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var names = await ( var names = await (
from slot in dbContext.Slots.AsNoTracking() from slot in dbContext.Slots.AsNoTracking()
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
where where
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
select show.Name select show.Name
) )
.Distinct() .Distinct()
.Take(2) .Take(2)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return ( return (
names.ElementAtOrDefault(0) ?? "Первое шоу", names.ElementAtOrDefault(0) ?? "Первое шоу",
names.ElementAtOrDefault(1) ?? "Второе шоу" names.ElementAtOrDefault(1) ?? "Второе шоу"
); );
} }
} }
@@ -1,64 +1,64 @@
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast; namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled); public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary> /// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection); public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary> /// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto( public sealed record BumperTextVariantDto(
Guid Id, Guid Id,
int Position, int Position,
string Name, string Name,
BumperTextKind Kind, BumperTextKind Kind,
string NowLabel, string NowLabel,
string NextLabel, string NextLabel,
string Line1, string Line1,
string Line2, string Line2,
BumperTrigger Trigger, BumperTrigger Trigger,
int Weight int Weight
); );
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary> /// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
public sealed record BumperTemplateDto( public sealed record BumperTemplateDto(
Guid Id, Guid Id,
int Position, int Position,
bool IsDefault, bool IsDefault,
string Name, string Name,
string BackgroundColor, string BackgroundColor,
string BackgroundColor2, string BackgroundColor2,
string AccentColor, string AccentColor,
string TextColor, string TextColor,
Guid? BackgroundImageId, Guid? BackgroundImageId,
bool HasAudio, bool HasAudio,
double? AudioDurationSeconds, double? AudioDurationSeconds,
IReadOnlyList<BumperTextVariantDto> Variants IReadOnlyList<BumperTextVariantDto> Variants
); );
public sealed record ChannelDto( public sealed record ChannelDto(
Guid Id, Guid Id,
string Name, string Name,
string Slug, string Slug,
bool IsEnabled, bool IsEnabled,
int? Number, int? Number,
int UtcOffsetMinutes, int UtcOffsetMinutes,
TimeOnly DayStartTime, TimeOnly DayStartTime,
Guid? TemplateId, Guid? TemplateId,
bool BumpersEnabled, bool BumpersEnabled,
BumperSettingsDto Bumper, BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates, IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId, Guid? FillerAssetId,
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary> /// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
ViewerSettingsDto Viewer ViewerSettingsDto Viewer
); );
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary> /// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
public sealed record ViewerSettingsDto( public sealed record ViewerSettingsDto(
Guid? LogoImageId, Guid? LogoImageId,
LogoCorner LogoCorner, LogoCorner LogoCorner,
double LogoOpacity, double LogoOpacity,
bool ShowClock, bool ShowClock,
double AnalogFilterStrength double AnalogFilterStrength
); );
@@ -1,53 +1,53 @@
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast; namespace TeleWave.Application.Broadcast;
public static class ChannelErrors public static class ChannelErrors
{ {
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден."); public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
public static readonly Error NumberTaken = Error.Conflict( public static readonly Error NumberTaken = Error.Conflict(
"Channels.NumberTaken", "Channels.NumberTaken",
"Канал с таким номером уже есть." "Канал с таким номером уже есть."
); );
public static readonly Error TemplateNotFound = Error.NotFound( public static readonly Error TemplateNotFound = Error.NotFound(
"Channels.TemplateNotFound", "Channels.TemplateNotFound",
"У канала нет шаблона сетки." "У канала нет шаблона сетки."
); );
public static readonly Error DuplicateSlug = Error.Conflict( public static readonly Error DuplicateSlug = Error.Conflict(
"Channels.DuplicateSlug", "Channels.DuplicateSlug",
"Канал с таким slug уже существует." "Канал с таким slug уже существует."
); );
public static readonly Error BumperTemplateNotFound = Error.NotFound( public static readonly Error BumperTemplateNotFound = Error.NotFound(
"Channels.BumperTemplateNotFound", "Channels.BumperTemplateNotFound",
"Блок заставки не найден." "Блок заставки не найден."
); );
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation( public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
"Channels.CannotRemoveDefaultBumperTemplate", "Channels.CannotRemoveDefaultBumperTemplate",
"Дефолтный блок заставки удалить нельзя." "Дефолтный блок заставки удалить нельзя."
); );
public static readonly Error BumperTextVariantNotFound = Error.NotFound( public static readonly Error BumperTextVariantNotFound = Error.NotFound(
"Channels.BumperTextVariantNotFound", "Channels.BumperTextVariantNotFound",
"Подблок заставки не найден." "Подблок заставки не найден."
); );
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation( public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
"Channels.CannotRemoveLastBumperTextVariant", "Channels.CannotRemoveLastBumperTextVariant",
"Нельзя удалить последний подблок — нужен хотя бы один." "Нельзя удалить последний подблок — нужен хотя бы один."
); );
public static readonly Error AssetNotFound = Error.NotFound( public static readonly Error AssetNotFound = Error.NotFound(
"Channels.AssetNotFound", "Channels.AssetNotFound",
"Медиа-ассет не найден." "Медиа-ассет не найден."
); );
public static readonly Error InvalidBumperFile = Error.Validation( public static readonly Error InvalidBumperFile = Error.Validation(
"Channels.InvalidBumperFile", "Channels.InvalidBumperFile",
"Недопустимый файл заставки (формат или размер)." "Недопустимый файл заставки (формат или размер)."
); );
} }
@@ -1,82 +1,82 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.GetChannel; namespace TeleWave.Application.Broadcast.GetChannel;
public sealed class GetChannelQueryHandler(IAppDbContext dbContext) public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelQuery, Result<ChannelDto>> : IQueryHandler<GetChannelQuery, Result<ChannelDto>>
{ {
public async Task<Result<ChannelDto>> Handle( public async Task<Result<ChannelDto>> Handle(
GetChannelQuery query, GetChannelQuery query,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно // Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
// (GetChannelTemplateQuery) — здесь только собственные свойства канала. // (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext var channel = await dbContext
.Channels.AsNoTracking() .Channels.AsNoTracking()
.Include(c => c.BumperTemplates) .Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants) .ThenInclude(t => t.Variants)
.AsSplitQuery() .AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken); .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null) if (channel is null)
return Result.Failure<ChannelDto>(ChannelErrors.NotFound); return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
var bumperTemplates = channel var bumperTemplates = channel
.BumperTemplates.OrderBy(t => t.Position) .BumperTemplates.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto( .Select(t => new BumperTemplateDto(
t.Id, t.Id,
t.Position, t.Position,
t.IsDefault, t.IsDefault,
t.Name, t.Name,
t.BackgroundColor, t.BackgroundColor,
t.BackgroundColor2, t.BackgroundColor2,
t.AccentColor, t.AccentColor,
t.TextColor, t.TextColor,
t.BackgroundImageId, t.BackgroundImageId,
t.AudioExtension is not null, t.AudioExtension is not null,
t.AudioDurationSeconds, t.AudioDurationSeconds,
t.Variants.OrderBy(v => v.Position) t.Variants.OrderBy(v => v.Position)
.Select(v => new BumperTextVariantDto( .Select(v => new BumperTextVariantDto(
v.Id, v.Id,
v.Position, v.Position,
v.Name, v.Name,
v.Kind, v.Kind,
v.NowLabel, v.NowLabel,
v.NextLabel, v.NextLabel,
v.Line1, v.Line1,
v.Line2, v.Line2,
v.Trigger, v.Trigger,
v.Weight v.Weight
)) ))
.ToList() .ToList()
)) ))
.ToList(); .ToList();
return Result.Success( return Result.Success(
new ChannelDto( new ChannelDto(
channel.Id, channel.Id,
channel.Name, channel.Name,
channel.Slug, channel.Slug,
channel.IsEnabled, channel.IsEnabled,
channel.Number, channel.Number,
channel.UtcOffsetMinutes, channel.UtcOffsetMinutes,
channel.DayStartTime, channel.DayStartTime,
channel.TemplateId, channel.TemplateId,
channel.BumpersEnabled, channel.BumpersEnabled,
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection), new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
bumperTemplates, bumperTemplates,
channel.FillerAssetId, channel.FillerAssetId,
new ViewerSettingsDto( new ViewerSettingsDto(
channel.LogoImageId, channel.LogoImageId,
channel.LogoCorner, channel.LogoCorner,
channel.LogoOpacity, channel.LogoOpacity,
channel.ShowClock, channel.ShowClock,
channel.AnalogFilterStrength channel.AnalogFilterStrength
) )
) )
); );
} }
} }
@@ -1,21 +1,21 @@
namespace TeleWave.Application.Broadcast.Scheduling; namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions public sealed class SchedulerOptions
{ {
public const string SectionName = "Scheduler"; public const string SectionName = "Scheduler";
/// <summary> /// <summary>
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла: /// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее. /// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
/// </summary> /// </summary>
public int HorizonDays { get; init; } = 7; public int HorizonDays { get; init; } = 7;
/// <summary> /// <summary>
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил /// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет. /// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
/// </summary> /// </summary>
public int RetentionDays { get; init; } = 90; public int RetentionDays { get; init; } = 90;
/// <summary>Период тика фонового планировщика, минуты.</summary> /// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30; public int TickMinutes { get; init; } = 30;
} }
@@ -1,18 +1,18 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings; namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed record UpdateChannelSettingsCommand( public sealed record UpdateChannelSettingsCommand(
Guid ChannelId, Guid ChannelId,
string Name, string Name,
bool IsEnabled, bool IsEnabled,
bool BumpersEnabled, bool BumpersEnabled,
BumperSettingsInput Bumper, BumperSettingsInput Bumper,
Guid? FillerAssetId Guid? FillerAssetId
) : ICommand<Result>; ) : ICommand<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия /// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
/// показа сюда не входят — они задаются на элементе стыка.</summary> /// показа сюда не входят — они задаются на элементе стыка.</summary>
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection); public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
@@ -1,42 +1,42 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings; namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelSettingsCommand, Result> : ICommandHandler<UpdateChannelSettingsCommand, Result>
{ {
public async Task<Result> Handle( public async Task<Result> Handle(
UpdateChannelSettingsCommand command, UpdateChannelSettingsCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext.Channels.FirstOrDefaultAsync( var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId, c => c.Id == command.ChannelId,
cancellationToken cancellationToken
); );
if (channel is null) if (channel is null)
return Result.Failure(ChannelErrors.NotFound); return Result.Failure(ChannelErrors.NotFound);
if (command.FillerAssetId is { } fillerId) if (command.FillerAssetId is { } fillerId)
{ {
var exists = await dbContext.MediaAssets.AnyAsync( var exists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == fillerId, a => a.Id == fillerId,
cancellationToken cancellationToken
); );
if (!exists) if (!exists)
return Result.Failure(ChannelErrors.AssetNotFound); return Result.Failure(ChannelErrors.AssetNotFound);
} }
channel.UpdateSettings( channel.UpdateSettings(
command.Name, command.Name,
command.IsEnabled, command.IsEnabled,
command.BumpersEnabled, command.BumpersEnabled,
command.FillerAssetId command.FillerAssetId
); );
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection); channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
return Result.Success(); return Result.Success();
} }
} }
@@ -1,12 +1,12 @@
using FluentValidation; using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings; namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand> : AbstractValidator<UpdateChannelSettingsCommand>
{ {
public UpdateChannelSettingsCommandValidator() public UpdateChannelSettingsCommandValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
} }
} }
@@ -1,48 +1,48 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Domain.Auth; using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images; using TeleWave.Domain.Images;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings; using TeleWave.Domain.Settings;
namespace TeleWave.Application.Common.Interfaces; namespace TeleWave.Application.Common.Interfaces;
public interface IAppDbContext public interface IAppDbContext
{ {
DbSet<RefreshToken> RefreshTokens { get; } DbSet<RefreshToken> RefreshTokens { get; }
DbSet<MediaAsset> MediaAssets { get; } DbSet<MediaAsset> MediaAssets { get; }
DbSet<Show> Shows { get; } DbSet<Show> Shows { get; }
DbSet<Genre> Genres { get; } DbSet<Genre> Genres { get; }
DbSet<GenreAlias> GenreAliases { get; } DbSet<GenreAlias> GenreAliases { get; }
DbSet<ShowGenre> ShowGenres { get; } DbSet<ShowGenre> ShowGenres { get; }
DbSet<Collection> Collections { get; } DbSet<Collection> Collections { get; }
DbSet<CollectionItem> CollectionItems { get; } DbSet<CollectionItem> CollectionItems { get; }
DbSet<Group> Groups { get; } DbSet<Group> Groups { get; }
DbSet<GroupItem> GroupItems { get; } DbSet<GroupItem> GroupItems { get; }
DbSet<ScheduleTemplate> ScheduleTemplates { get; } DbSet<ScheduleTemplate> ScheduleTemplates { get; }
DbSet<GridLayer> GridLayers { get; } DbSet<GridLayer> GridLayers { get; }
DbSet<Slot> Slots { get; } DbSet<Slot> Slots { get; }
DbSet<SlotState> SlotStates { get; } DbSet<SlotState> SlotStates { get; }
DbSet<JunctionTemplate> JunctionTemplates { get; } DbSet<JunctionTemplate> JunctionTemplates { get; }
DbSet<JunctionElement> JunctionElements { get; } DbSet<JunctionElement> JunctionElements { get; }
DbSet<Channel> Channels { get; } DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; } DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; } DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<BumperAsset> BumperAssets { get; } DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; } DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; } DbSet<Image> Images { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken); Task<int> SaveChangesAsync(CancellationToken cancellationToken);
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч. /// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary> /// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken); Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате). /// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией. /// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
/// Вызывать внутри открытой транзакции.</summary> /// Вызывать внутри открытой транзакции.</summary>
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken); Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
} }
@@ -1,63 +1,63 @@
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
namespace TeleWave.Application.Common.Interfaces; namespace TeleWave.Application.Common.Interfaces;
/// <summary> /// <summary>
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня /// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации. /// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
/// </summary> /// </summary>
public interface IMediaStorage public interface IMediaStorage
{ {
/// <summary>Свободное место на томе хранилища, байт.</summary> /// <summary>Свободное место на томе хранилища, байт.</summary>
long GetAvailableFreeSpaceBytes(); long GetAvailableFreeSpaceBytes();
/// <summary> /// <summary>
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память. /// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>. /// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
/// </summary> /// </summary>
Task<string> SaveUploadAsync( Task<string> SaveUploadAsync(
Stream content, Stream content,
string extension, string extension,
CancellationToken cancellationToken CancellationToken cancellationToken
); );
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary> /// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken); Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
/// <summary>Файл ручного inbox.</summary> /// <summary>Файл ручного inbox.</summary>
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param> /// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes); public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
/// <summary> /// <summary>
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу: /// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
/// каталог наполняет человек, и он может оказаться большим. /// каталог наполняет человек, и он может оказаться большим.
/// </summary> /// </summary>
IReadOnlyList<ManualInboxFile> ListManualInbox(int max); IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary> /// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем /// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы /// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
/// не трогает — рядом может лежать следующая серия. /// не трогает — рядом может лежать следующая серия.
/// </summary> /// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken); Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется /// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно /// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
/// переносит — файл из каталога-источника уходит. /// переносит — файл из каталога-источника уходит.
/// </summary> /// </summary>
Task PromoteToOriginalAsync( Task PromoteToOriginalAsync(
MediaSource source, MediaSource source,
string sourceToken, string sourceToken,
Guid assetId, Guid assetId,
string extension, string extension,
CancellationToken cancellationToken CancellationToken cancellationToken
); );
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary> /// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
Task DeleteAssetArtifactsAsync( Task DeleteAssetArtifactsAsync(
Guid assetId, Guid assetId,
string extension, string extension,
CancellationToken cancellationToken CancellationToken cancellationToken
); );
} }
@@ -1,21 +1,21 @@
namespace TeleWave.Application.Common.Interfaces; namespace TeleWave.Application.Common.Interfaces;
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary> /// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
public interface ISiteSettings public interface ISiteSettings
{ {
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken); Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary> /// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken); Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета /// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»); пусто — без предпочтения.</summary> /// (напр. «rus,eng»); пусто — без предпочтения.</summary>
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken); Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken); Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary> /// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken); Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken); Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
} }
@@ -1,89 +1,89 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.ListShows; namespace TeleWave.Application.Library.ListShows;
public sealed class ListShowsQueryHandler(IAppDbContext dbContext) public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>> : IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
{ {
public async Task<IReadOnlyList<ShowSummaryDto>> Handle( public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
ListShowsQuery query, ListShowsQuery query,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var source = dbContext var source = dbContext
.Shows.AsNoTracking() .Shows.AsNoTracking()
.Include(s => s.Episodes) .Include(s => s.Episodes)
.Include(s => s.Genres) .Include(s => s.Genres)
// Серии и жанры — сиблинги: одним запросом это честное перемножение (сериал на 200 серий // Серии и жанры — сиблинги: одним запросом это честное перемножение (сериал на 200 серий
// с тремя жанрами даёт 600 строк вместо 203). Разделяем. // с тремя жанрами даёт 600 строк вместо 203). Разделяем.
.AsSplitQuery() .AsSplitQuery()
.Where(s => .Where(s =>
query.Interstitials query.Interstitials
? s.Kind == ShowKind.Interstitial ? s.Kind == ShowKind.Interstitial
: s.Kind != ShowKind.Interstitial : s.Kind != ShowKind.Interstitial
); );
var filtered = query.GenreId is { } genreId var filtered = query.GenreId is { } genreId
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId)) ? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
: source; : source;
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken); var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
// Названия только для основных жанров — в списке показывается один. // Названия только для основных жанров — в списке показывается один.
var primaryIds = shows var primaryIds = shows
.Select(s => s.PrimaryGenreId) .Select(s => s.PrimaryGenreId)
.Where(id => id is not null) .Where(id => id is not null)
.Select(id => id!.Value) .Select(id => id!.Value)
.Distinct() .Distinct()
.ToList(); .ToList();
var genreNames = await dbContext var genreNames = await dbContext
.Genres.AsNoTracking() .Genres.AsNoTracking()
.Where(g => primaryIds.Contains(g.Id)) .Where(g => primaryIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся). // Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
var assetIds = shows var assetIds = shows
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)) .SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
.Distinct() .Distinct()
.ToList(); .ToList();
var names = await dbContext var names = await dbContext
.MediaAssets.AsNoTracking() .MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id)) .Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName }) .Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
return shows return shows
.Select(s => .Select(s =>
{ {
var seasons = s var seasons = s
.Episodes.Select(e => .Episodes.Select(e =>
names.TryGetValue(e.MediaAssetId, out var n) names.TryGetValue(e.MediaAssetId, out var n)
? EpisodeName.ParseSeason(n) ? EpisodeName.ParseSeason(n)
: null : null
) )
.Where(season => season is not null) .Where(season => season is not null)
.Distinct() .Distinct()
.Count(); .Count();
return new ShowSummaryDto( return new ShowSummaryDto(
s.Id, s.Id,
s.Name, s.Name,
s.OriginalName, s.OriginalName,
s.Kind, s.Kind,
s.Audience, s.Audience,
s.Episodes.Count, s.Episodes.Count,
seasons, seasons,
s.Year, s.Year,
s.PosterImageId is not null, s.PosterImageId is not null,
s.CreatedAt, s.CreatedAt,
s.PrimaryGenreId is { } primaryId s.PrimaryGenreId is { } primaryId
&& genreNames.TryGetValue(primaryId, out var g) && genreNames.TryGetValue(primaryId, out var g)
? g ? g
: null : null
); );
}) })
.ToList(); .ToList();
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,237 +1,237 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning; using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning; namespace TeleWave.Application.Programming.Planning;
/// <summary> /// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал — /// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается), /// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу. /// фильм — в одну единицу.
/// ///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё /// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче. /// обрабатывается, значит получить дыру в раздаче.
/// </summary> /// </summary>
public sealed class GroupExpander(IAppDbContext dbContext) public sealed class GroupExpander(IAppDbContext dbContext)
{ {
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов; /// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param> /// null — история не нужна и не загружается.</param>
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync( public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds, IReadOnlyCollection<Guid> groupIds,
Guid channelId, Guid channelId,
CancellationToken cancellationToken, CancellationToken cancellationToken,
DateTimeOffset? historyFrom = null DateTimeOffset? historyFrom = null
) )
{ {
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>(); var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
if (groupIds.Count == 0) if (groupIds.Count == 0)
return result; return result;
var groups = await dbContext var groups = await dbContext
.Groups.AsNoTracking() .Groups.AsNoTracking()
.Include(g => g.Items) .Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id)) .Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var collectionIds = groups var collectionIds = groups
.SelectMany(g => g.Items) .SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection) .Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId) .Select(i => i.ElementId)
.Distinct() .Distinct()
.ToList(); .ToList();
var collectionParts = await dbContext var collectionParts = await dbContext
.CollectionItems.AsNoTracking() .CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId)) .Where(i => collectionIds.Contains(i.CollectionId))
.OrderBy(i => i.Position) .OrderBy(i => i.Position)
.Select(i => new { i.CollectionId, i.ShowId }) .Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var showIds = groups var showIds = groups
.SelectMany(g => g.Items) .SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show) .Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId) .Select(i => i.ElementId)
.Concat(collectionParts.Select(p => p.ShowId)) .Concat(collectionParts.Select(p => p.ShowId))
.Distinct() .Distinct()
.ToList(); .ToList();
var loaded = await dbContext var loaded = await dbContext
.Shows.AsNoTracking() .Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id)) .Where(s => showIds.Contains(s.Id))
.Select(s => new .Select(s => new
{ {
s.Id, s.Id,
s.Audience, s.Audience,
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(), AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds)); var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList(); var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
var durations = await dbContext var durations = await dbContext
.MediaAssets.AsNoTracking() .MediaAssets.AsNoTracking()
.Where(a => .Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
) )
.Select(a => new { a.Id, a.Duration }) .Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken); .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken); var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
var recentPlays = await LoadRecentPlaysAsync( var recentPlays = await LoadRecentPlaysAsync(
channelId, channelId,
showIds, showIds,
historyFrom, historyFrom,
cancellationToken cancellationToken
); );
var data = new ExpansionData( var data = new ExpansionData(
shows, shows,
durations, durations,
lastPlayed, lastPlayed,
recentPlays, recentPlays,
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId) collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
); );
foreach (var group in groups) foreach (var group in groups)
result[group.Id] = group result[group.Id] = group
.Items.OrderBy(i => i.Position) .Items.OrderBy(i => i.Position)
.Select(item => .Select(item =>
item.ElementKind == GroupElementKind.Show item.ElementKind == GroupElementKind.Show
? data.ShowElement(item) ? data.ShowElement(item)
: data.CollectionElement(item) : data.CollectionElement(item)
) )
.ToList(); .ToList();
return result; return result;
} }
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary> /// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds); private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
/// <summary> /// <summary>
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>: /// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком. /// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
/// В БД отсюда не ходят — всё уже загружено. /// В БД отсюда не ходят — всё уже загружено.
/// </summary> /// </summary>
private sealed record ExpansionData( private sealed record ExpansionData(
IReadOnlyDictionary<Guid, ShowRow> Shows, IReadOnlyDictionary<Guid, ShowRow> Shows,
IReadOnlyDictionary<Guid, TimeSpan> Durations, IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed, IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays, IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
ILookup<Guid, Guid> CollectionParts ILookup<Guid, Guid> CollectionParts
) )
{ {
public PlanningElement ShowElement(GroupItem item) => public PlanningElement ShowElement(GroupItem item) =>
new( new(
item.ElementKind, item.ElementKind,
item.ElementId, item.ElementId,
item.Weight, item.Weight,
item.Position, item.Position,
UnitsOf(item.ElementId), UnitsOf(item.ElementId),
LastPlayedOf(item.ElementId), LastPlayedOf(item.ElementId),
AudienceOf(item.ElementId), AudienceOf(item.ElementId),
RecentPlaysOf(item.ElementId) RecentPlaysOf(item.ElementId)
); );
/// <summary> /// <summary>
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей /// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший /// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю). /// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
/// </summary> /// </summary>
public PlanningElement CollectionElement(GroupItem item) public PlanningElement CollectionElement(GroupItem item)
{ {
var parts = CollectionParts[item.ElementId].ToList(); var parts = CollectionParts[item.ElementId].ToList();
return new PlanningElement( return new PlanningElement(
item.ElementKind, item.ElementKind,
item.ElementId, item.ElementId,
item.Weight, item.Weight,
item.Position, item.Position,
parts.SelectMany(UnitsOf).ToList(), parts.SelectMany(UnitsOf).ToList(),
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(), parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(), parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList() parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
); );
} }
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary> /// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
private List<PlanningUnit> UnitsOf(Guid showId) => private List<PlanningUnit> UnitsOf(Guid showId) =>
Shows.TryGetValue(showId, out var show) Shows.TryGetValue(showId, out var show)
? show ? show
.AssetIds.Where(Durations.ContainsKey) .AssetIds.Where(Durations.ContainsKey)
.Select( .Select(
(assetId, index) => (assetId, index) =>
new PlanningUnit(assetId, Durations[assetId], showId, index) new PlanningUnit(assetId, Durations[assetId], showId, index)
) )
.ToList() .ToList()
: []; : [];
private DateTimeOffset? LastPlayedOf(Guid showId) => private DateTimeOffset? LastPlayedOf(Guid showId) =>
LastPlayed.TryGetValue(showId, out var at) ? at : null; LastPlayed.TryGetValue(showId, out var at) ? at : null;
private ShowAudience? AudienceOf(Guid showId) => private ShowAudience? AudienceOf(Guid showId) =>
Shows.TryGetValue(showId, out var show) ? show.Audience : null; Shows.TryGetValue(showId, out var show) ? show.Audience : null;
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) => private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
RecentPlays.TryGetValue(showId, out var plays) ? plays : null; RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
} }
/// <summary> /// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного /// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное /// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил. /// остывание среди правил.
/// </summary> /// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync( private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId, Guid channelId,
IReadOnlyCollection<Guid> showIds, IReadOnlyCollection<Guid> showIds,
CancellationToken cancellationToken CancellationToken cancellationToken
) => ) =>
await dbContext await dbContext
.ScheduleEntries.AsNoTracking() .ScheduleEntries.AsNoTracking()
.Where(e => .Where(e =>
e.ChannelId == channelId e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program && e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null && e.ShowId != null
&& showIds.Contains(e.ShowId.Value) && showIds.Contains(e.ShowId.Value)
) )
.GroupBy(e => e.ShowId!.Value) .GroupBy(e => e.ShowId!.Value)
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) }) .Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken); .ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
/// <summary> /// <summary>
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него /// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
/// это лишние сотни строк на каждый прогон. /// это лишние сотни строк на каждый прогон.
/// </summary> /// </summary>
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync( private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
Guid channelId, Guid channelId,
IReadOnlyCollection<Guid> showIds, IReadOnlyCollection<Guid> showIds,
DateTimeOffset? from, DateTimeOffset? from,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (from is not { } since || showIds.Count == 0) if (from is not { } since || showIds.Count == 0)
return []; return [];
var plays = await dbContext var plays = await dbContext
.ScheduleEntries.AsNoTracking() .ScheduleEntries.AsNoTracking()
.Where(e => .Where(e =>
e.ChannelId == channelId e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program && e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null && e.ShowId != null
&& showIds.Contains(e.ShowId.Value) && showIds.Contains(e.ShowId.Value)
&& e.StartsAtUtc >= since && e.StartsAtUtc >= since
) )
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc }) .Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return plays return plays
.GroupBy(p => p.ShowId) .GroupBy(p => p.ShowId)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList() g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
); );
} }
} }
@@ -1,39 +1,39 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning; using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace; namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary> /// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации /// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются. /// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary> /// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>; public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto( public sealed record EntryTraceDto(
Guid EntryId, Guid EntryId,
DateTimeOffset StartsAtUtc, DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc, DateTimeOffset EndsAtUtc,
string? ShowName, string? ShowName,
int? EpisodeIndex, int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary> /// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName, string? LayerName,
int? LayerPriority, int? LayerPriority,
string? SlotTitle, string? SlotTitle,
SlotKind? SlotKind, SlotKind? SlotKind,
int? SlotWeekday, int? SlotWeekday,
TimeOnly? SlotTargetStart, TimeOnly? SlotTargetStart,
int? SlotDurationMinutes, int? SlotDurationMinutes,
string? GroupName, string? GroupName,
int? GroupItemCount, int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary> /// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName, string? CollectionName,
SlotStrategyKind? Strategy, SlotStrategyKind? Strategy,
int? CooldownDays, int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary> /// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown, int? CandidatesAfterCooldown,
int DriftMinutes, int DriftMinutes,
bool Snapped, bool Snapped,
string? JunctionName string? JunctionName
); );
@@ -1,152 +1,152 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning; using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace; namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext) public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>> : IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{ {
/// <summary>Те же настройки, что при записи трейса генератором.</summary> /// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new() private static readonly JsonSerializerOptions TraceJsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() }, Converters = { new JsonStringEnumConverter() },
}; };
public async Task<Result<EntryTraceDto>> Handle( public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query, GetEntryTraceQuery query,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var entry = await dbContext var entry = await dbContext
.ScheduleEntries.AsNoTracking() .ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken); .FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null) if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound); return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName = entry.ShowId is { } showId var showName = entry.ShowId is { } showId
? await dbContext ? await dbContext
.Shows.AsNoTracking() .Shows.AsNoTracking()
.Where(s => s.Id == showId) .Where(s => s.Id == showId)
.Select(s => s.Name) .Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
: null; : null;
var collectionName = entry.CollectionId is { } collectionId var collectionName = entry.CollectionId is { } collectionId
? await dbContext ? await dbContext
.Collections.AsNoTracking() .Collections.AsNoTracking()
.Where(c => c.Id == collectionId) .Where(c => c.Id == collectionId)
.Select(c => c.Name) .Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
: null; : null;
var trace = Parse(entry.TraceJson); var trace = Parse(entry.TraceJson);
if (trace is null) if (trace is null)
return Result.Success( return Result.Success(
new EntryTraceDto( new EntryTraceDto(
entry.Id, entry.Id,
entry.StartsAtUtc, entry.StartsAtUtc,
entry.EndsAtUtc, entry.EndsAtUtc,
showName, showName,
entry.EpisodeIndex, entry.EpisodeIndex,
null, null,
null, null,
null, null,
null, null,
null, null,
null, null,
null, null,
null, null,
null, null,
collectionName, collectionName,
null, null,
null, null,
null, null,
0, 0,
false, false,
null null
) )
); );
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто // Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой. // часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId var slot = trace.SlotId is { } slotId
? await dbContext ? await dbContext
.Slots.AsNoTracking() .Slots.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken) .FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null; : null;
var layer = slot is null var layer = slot is null
? null ? null
: await dbContext : await dbContext
.GridLayers.AsNoTracking() .GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken); .FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId var group = slot?.GroupId is { } groupId
? await dbContext ? await dbContext
.Groups.AsNoTracking() .Groups.AsNoTracking()
.Where(g => g.Id == groupId) .Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount }) .Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
: null; : null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson); var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId; var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id var junctionName = junctionId is { } id
? await dbContext ? await dbContext
.JunctionTemplates.AsNoTracking() .JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id) .Where(j => j.Id == id)
.Select(j => j.Name) .Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
: null; : null;
return Result.Success( return Result.Success(
new EntryTraceDto( new EntryTraceDto(
entry.Id, entry.Id,
entry.StartsAtUtc, entry.StartsAtUtc,
entry.EndsAtUtc, entry.EndsAtUtc,
showName, showName,
entry.EpisodeIndex, entry.EpisodeIndex,
layer?.Name, layer?.Name,
layer?.Priority, layer?.Priority,
slot?.Title, slot?.Title,
trace.SlotKind, trace.SlotKind,
slot?.Weekday, slot?.Weekday,
slot?.TargetStart, slot?.TargetStart,
slot?.TargetDurationMinutes, slot?.TargetDurationMinutes,
group?.Name, group?.Name,
group?.ItemCount, group?.ItemCount,
collectionName, collectionName,
trace.Strategy, trace.Strategy,
strategy?.CooldownDays, strategy?.CooldownDays,
trace.CandidatesAfterCooldown, trace.CandidatesAfterCooldown,
trace.DriftMinutes, trace.DriftMinutes,
trace.Snapped, trace.Snapped,
junctionName junctionName
) )
); );
} }
private static PlanTrace? Parse(string? json) private static PlanTrace? Parse(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
return null; return null;
try try
{ {
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions); return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
} }
catch (JsonException) catch (JsonException)
{ {
return null; return null;
} }
} }
} }
@@ -1,231 +1,231 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.CopyTemplate; namespace TeleWave.Application.Programming.Templates.CopyTemplate;
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext) public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>> : ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
{ {
public async Task<Result<CopyTemplateResultDto>> Handle( public async Task<Result<CopyTemplateResultDto>> Handle(
CopyTemplateCommand command, CopyTemplateCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var target = await dbContext var target = await dbContext
.Channels.Include(c => c.BumperTemplates) .Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken); .FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
if (target is null) if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound); return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
var source = await dbContext var source = await dbContext
.ScheduleTemplates.AsNoTracking() .ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers) .Include(t => t.Layers)
.ThenInclude(l => l.Slots) .ThenInclude(l => l.Slots)
.AsSplitQuery() .AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken); .FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
if (source is null) if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound); return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking() .JunctionTemplates.AsNoTracking()
.Include(j => j.Elements) .Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId) .Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же // Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте. // именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name) .BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id); .ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext var sourceBumperNames = await dbContext
.Channels.AsNoTracking() .Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId) .Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates) .SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name }) .Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken); .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var (junctionMap, droppedBumperRefs) = CopyJunctions( var (junctionMap, droppedBumperRefs) = CopyJunctions(
sourceJunctions, sourceJunctions,
target.Id, target.Id,
sourceBumperNames, sourceBumperNames,
bumperByName bumperByName
); );
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя, // Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов. // а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id) .ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing); dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext await dbContext
.JunctionTemplates.Where(j => .JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id) j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
) )
.ExecuteDeleteAsync(cancellationToken); .ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name); var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId); copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson); copyTemplate.SetRules(source.RulesJson);
if ( if (
source.DefaultJunctionId is { } defaultJunction source.DefaultJunctionId is { } defaultJunction
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault) && junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
) )
copyTemplate.SetDefaultJunction(mappedDefault); copyTemplate.SetDefaultJunction(mappedDefault);
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap); var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
dbContext.ScheduleTemplates.Add(copyTemplate); dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id); target.SetTemplate(copyTemplate.Id);
return Result.Success( return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs) new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
); );
} }
/// <summary> /// <summary>
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему /// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки. /// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
/// </summary> /// </summary>
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions( private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
IReadOnlyList<JunctionTemplate> sourceJunctions, IReadOnlyList<JunctionTemplate> sourceJunctions,
Guid targetChannelId, Guid targetChannelId,
IReadOnlyDictionary<Guid, string> sourceBumperNames, IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName IReadOnlyDictionary<string, Guid> targetBumperByName
) )
{ {
var map = new Dictionary<Guid, Guid>(); var map = new Dictionary<Guid, Guid>();
var dropped = 0; var dropped = 0;
foreach (var junction in sourceJunctions) foreach (var junction in sourceJunctions)
{ {
var copy = JunctionTemplate.Create(targetChannelId, junction.Name); var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
map[junction.Id] = copy.Id; map[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position)) foreach (var element in junction.Elements.OrderBy(e => e.Position))
{ {
var bumperTemplateId = MapBumper( var bumperTemplateId = MapBumper(
element, element,
sourceBumperNames, sourceBumperNames,
targetBumperByName, targetBumperByName,
ref dropped ref dropped
); );
copy.AddElement(element.Kind) copy.AddElement(element.Kind)
.Update( .Update(
element.Kind, element.Kind,
element.GroupId, element.GroupId,
bumperTemplateId, bumperTemplateId,
element.AmountMode, element.AmountMode,
element.AmountValue, element.AmountValue,
element.IsRequired, element.IsRequired,
element.ConditionsJson element.ConditionsJson
); );
} }
dbContext.JunctionTemplates.Add(copy); dbContext.JunctionTemplates.Add(copy);
} }
return (map, dropped); return (map, dropped);
} }
/// <summary> /// <summary>
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале /// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки, /// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
/// и это попадает в отчёт. /// и это попадает в отчёт.
/// </summary> /// </summary>
private static Guid? MapBumper( private static Guid? MapBumper(
JunctionElement element, JunctionElement element,
IReadOnlyDictionary<Guid, string> sourceBumperNames, IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName, IReadOnlyDictionary<string, Guid> targetBumperByName,
ref int dropped ref int dropped
) )
{ {
if (element.Kind != JunctionElementKind.Bumper) if (element.Kind != JunctionElementKind.Bumper)
return null; return null;
if ( if (
element.BumperTemplateId is { } sourceId element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name) && sourceBumperNames.TryGetValue(sourceId, out var name)
&& targetBumperByName.TryGetValue(name, out var mapped) && targetBumperByName.TryGetValue(name, out var mapped)
) )
return mapped; return mapped;
dropped++; dropped++;
return null; return null;
} }
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary> /// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
private static (int Layers, int Slots) CopyGrid( private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source, ScheduleTemplate source,
ScheduleTemplate copyTemplate, ScheduleTemplate copyTemplate,
IReadOnlyDictionary<Guid, Guid> junctionMap IReadOnlyDictionary<Guid, Guid> junctionMap
) )
{ {
var layers = 0; var layers = 0;
var slots = 0; var slots = 0;
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority)) foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
{ {
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй. // Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
var copyLayer = layer.IsBackground var copyLayer = layer.IsBackground
? copyTemplate.Background! ? copyTemplate.Background!
: copyTemplate.AddLayer(layer.Name, layer.Priority); : copyTemplate.AddLayer(layer.Name, layer.Priority);
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled); copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
if (!layer.IsBackground) if (!layer.IsBackground)
layers++; layers++;
foreach (var slot in layer.Slots) foreach (var slot in layer.Slots)
{ {
CopySlot(slot, copyLayer, junctionMap); CopySlot(slot, copyLayer, junctionMap);
slots++; slots++;
} }
} }
return (layers, slots); return (layers, slots);
} }
private static void CopySlot( private static void CopySlot(
Slot slot, Slot slot,
GridLayer copyLayer, GridLayer copyLayer,
IReadOnlyDictionary<Guid, Guid> junctionMap IReadOnlyDictionary<Guid, Guid> junctionMap
) )
{ {
var copySlot = copyLayer.AddSlot( var copySlot = copyLayer.AddSlot(
slot.Title, slot.Title,
slot.TargetStart, slot.TargetStart,
slot.TargetDurationMinutes, slot.TargetDurationMinutes,
slot.Daypart, slot.Daypart,
slot.SlotKind, slot.SlotKind,
slot.Weekday slot.Weekday
); );
copySlot.UpdateTiming( copySlot.UpdateTiming(
slot.Weekday, slot.Weekday,
slot.TargetStart, slot.TargetStart,
slot.TargetDurationMinutes, slot.TargetDurationMinutes,
slot.Daypart, slot.Daypart,
slot.IsAnchor, slot.IsAnchor,
slot.MaxDriftMinutes, slot.MaxDriftMinutes,
slot.SnapToMinutes slot.SnapToMinutes
); );
copySlot.UpdateContent( copySlot.UpdateContent(
new SlotContent( new SlotContent(
slot.Title, slot.Title,
slot.SlotKind, slot.SlotKind,
slot.GroupId, slot.GroupId,
slot.StrategyJson, slot.StrategyJson,
slot.RepeatSourceJson, slot.RepeatSourceJson,
slot.BlockMode, slot.BlockMode,
slot.BlockValue, slot.BlockValue,
slot.OverflowPolicy, slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap), Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap) Map(slot.JunctionAfterId, junctionMap)
) )
); );
} }
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) => private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null; id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
} }
@@ -1,102 +1,102 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.GetTemplate; namespace TeleWave.Application.Programming.Templates.GetTemplate;
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext) public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>> : IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
{ {
public async Task<Result<ScheduleTemplateDto>> Handle( public async Task<Result<ScheduleTemplateDto>> Handle(
GetChannelTemplateQuery query, GetChannelTemplateQuery query,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext var channel = await dbContext
.Channels.AsNoTracking() .Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null) if (channel is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound); return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
var template = await dbContext var template = await dbContext
.ScheduleTemplates.AsNoTracking() .ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers) .Include(t => t.Layers)
.ThenInclude(l => l.Slots) .ThenInclude(l => l.Slots)
.AsSplitQuery() .AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken); .FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null) if (template is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound); return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода. // Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
var groupIds = template var groupIds = template
.Layers.SelectMany(l => l.Slots) .Layers.SelectMany(l => l.Slots)
.Select(s => s.GroupId) .Select(s => s.GroupId)
.Where(id => id is not null) .Where(id => id is not null)
.Select(id => id!.Value) .Select(id => id!.Value)
.Distinct() .Distinct()
.ToList(); .ToList();
var groupNames = await dbContext var groupNames = await dbContext
.Groups.AsNoTracking() .Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id)) .Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var layers = template var layers = template
.Layers.OrderByDescending(l => l.Priority) .Layers.OrderByDescending(l => l.Priority)
.Select(layer => new GridLayerDto( .Select(layer => new GridLayerDto(
layer.Id, layer.Id,
layer.Name, layer.Name,
layer.Priority, layer.Priority,
layer.IsEnabled, layer.IsEnabled,
layer.IsBackground, layer.IsBackground,
LayerApplicability.FromJson(layer.ApplicabilityJson), LayerApplicability.FromJson(layer.ApplicabilityJson),
layer layer
.Slots.OrderBy(s => s.Weekday ?? -1) .Slots.OrderBy(s => s.Weekday ?? -1)
.ThenBy(s => s.TargetStart) .ThenBy(s => s.TargetStart)
.Select(slot => new SlotDto( .Select(slot => new SlotDto(
slot.Id, slot.Id,
slot.LayerId, slot.LayerId,
slot.Weekday, slot.Weekday,
slot.TargetStart, slot.TargetStart,
slot.TargetDurationMinutes, slot.TargetDurationMinutes,
slot.Title, slot.Title,
slot.Daypart, slot.Daypart,
slot.SlotKind, slot.SlotKind,
slot.GroupId, slot.GroupId,
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname) slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname ? gname
: null, : null,
SlotStrategy.FromJson(slot.StrategyJson), SlotStrategy.FromJson(slot.StrategyJson),
RepeatSource.FromJson(slot.RepeatSourceJson), RepeatSource.FromJson(slot.RepeatSourceJson),
slot.BlockMode, slot.BlockMode,
slot.BlockValue, slot.BlockValue,
slot.OverflowPolicy, slot.OverflowPolicy,
slot.IsAnchor, slot.IsAnchor,
slot.MaxDriftMinutes, slot.MaxDriftMinutes,
slot.SnapToMinutes, slot.SnapToMinutes,
slot.JunctionBetweenId, slot.JunctionBetweenId,
slot.JunctionAfterId slot.JunctionAfterId
)) ))
.ToList() .ToList()
)) ))
.ToList(); .ToList();
return Result.Success( return Result.Success(
new ScheduleTemplateDto( new ScheduleTemplateDto(
template.Id, template.Id,
template.ChannelId, template.ChannelId,
template.Name, template.Name,
template.FallbackGroupId, template.FallbackGroupId,
template.DefaultJunctionId, template.DefaultJunctionId,
PlanningRules.FromJson(template.RulesJson), PlanningRules.FromJson(template.RulesJson),
template.Revision, template.Revision,
template.AppliedRevision, template.AppliedRevision,
template.HasPendingChanges, template.HasPendingChanges,
channel.UtcOffsetMinutes, channel.UtcOffsetMinutes,
channel.DayStartTime, channel.DayStartTime,
layers layers
) )
); );
} }
} }
@@ -1,139 +1,139 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Layers; namespace TeleWave.Application.Programming.Templates.Layers;
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext) public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateLayerCommand, Result<Guid>> : ICommandHandler<CreateLayerCommand, Result<Guid>>
{ {
public async Task<Result<Guid>> Handle( public async Task<Result<Guid>> Handle(
CreateLayerCommand command, CreateLayerCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var template = await LayerLoader.ByTemplateAsync( var template = await LayerLoader.ByTemplateAsync(
dbContext, dbContext,
command.TemplateId, command.TemplateId,
cancellationToken cancellationToken
); );
if (template is null) if (template is null)
return Result.Failure<Guid>(TemplateErrors.NotFound); return Result.Failure<Guid>(TemplateErrors.NotFound);
var layer = template.AddLayer(command.Name, command.Priority); var layer = template.AddLayer(command.Name, command.Priority);
template.MarkChanged(); template.MarkChanged();
return Result.Success(layer.Id); return Result.Success(layer.Id);
} }
} }
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext) public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateLayerCommand, Result> : ICommandHandler<UpdateLayerCommand, Result>
{ {
public async Task<Result> Handle( public async Task<Result> Handle(
UpdateLayerCommand command, UpdateLayerCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var template = await LayerLoader.ByLayerAsync( var template = await LayerLoader.ByLayerAsync(
dbContext, dbContext,
command.LayerId, command.LayerId,
cancellationToken cancellationToken
); );
var layer = template?.FindLayer(command.LayerId); var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null) if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound); return Result.Failure(TemplateErrors.LayerNotFound);
layer.Update( layer.Update(
command.Name, command.Name,
command.Priority, command.Priority,
command.Applicability?.ToJson(), command.Applicability?.ToJson(),
command.IsEnabled command.IsEnabled
); );
template.MarkChanged(); template.MarkChanged();
return Result.Success(); return Result.Success();
} }
} }
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext) public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteLayerCommand, Result> : ICommandHandler<DeleteLayerCommand, Result>
{ {
public async Task<Result> Handle( public async Task<Result> Handle(
DeleteLayerCommand command, DeleteLayerCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var template = await LayerLoader.ByLayerAsync( var template = await LayerLoader.ByLayerAsync(
dbContext, dbContext,
command.LayerId, command.LayerId,
cancellationToken cancellationToken
); );
var layer = template?.FindLayer(command.LayerId); var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null) if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound); return Result.Failure(TemplateErrors.LayerNotFound);
if (layer.IsBackground) if (layer.IsBackground)
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted); return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
template.RemoveLayer(command.LayerId); template.RemoveLayer(command.LayerId);
template.MarkChanged(); template.MarkChanged();
return Result.Success(); return Result.Success();
} }
} }
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext) public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateTemplateCommand, Result> : ICommandHandler<UpdateTemplateCommand, Result>
{ {
public async Task<Result> Handle( public async Task<Result> Handle(
UpdateTemplateCommand command, UpdateTemplateCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync( var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.Id == command.TemplateId, t => t.Id == command.TemplateId,
cancellationToken cancellationToken
); );
if (template is null) if (template is null)
return Result.Failure(TemplateErrors.NotFound); return Result.Failure(TemplateErrors.NotFound);
if ( if (
command.FallbackGroupId is { } groupId command.FallbackGroupId is { } groupId
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken) && !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
) )
return Result.Failure(TemplateErrors.GroupNotFound); return Result.Failure(TemplateErrors.GroupNotFound);
template.Rename(command.Name); template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId); template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId); template.SetDefaultJunction(command.DefaultJunctionId);
template.SetRules(command.Rules?.ToJson()); template.SetRules(command.Rules?.ToJson());
template.MarkChanged(); template.MarkChanged();
return Result.Success(); return Result.Success();
} }
} }
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary> /// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
internal static class LayerLoader internal static class LayerLoader
{ {
public static Task<ScheduleTemplate?> ByTemplateAsync( public static Task<ScheduleTemplate?> ByTemplateAsync(
IAppDbContext dbContext, IAppDbContext dbContext,
Guid templateId, Guid templateId,
CancellationToken cancellationToken CancellationToken cancellationToken
) => ) =>
dbContext dbContext
.ScheduleTemplates.Include(t => t.Layers) .ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots) .ThenInclude(l => l.Slots)
.AsSplitQuery() .AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken); .FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
public static Task<ScheduleTemplate?> ByLayerAsync( public static Task<ScheduleTemplate?> ByLayerAsync(
IAppDbContext dbContext, IAppDbContext dbContext,
Guid layerId, Guid layerId,
CancellationToken cancellationToken CancellationToken cancellationToken
) => ) =>
dbContext dbContext
.ScheduleTemplates.Include(t => t.Layers) .ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots) .ThenInclude(l => l.Slots)
.AsSplitQuery() .AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken); .FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
} }
@@ -1,67 +1,67 @@
using FluentValidation; using FluentValidation;
using LiteCqrs; using LiteCqrs;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Layers; namespace TeleWave.Application.Programming.Templates.Layers;
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority) public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
: ICommand<Result<Guid>>; : ICommand<Result<Guid>>;
public sealed record UpdateLayerCommand( public sealed record UpdateLayerCommand(
Guid LayerId, Guid LayerId,
string Name, string Name,
int Priority, int Priority,
LayerApplicability? Applicability, LayerApplicability? Applicability,
bool IsEnabled bool IsEnabled
) : ICommand<Result>; ) : ICommand<Result>;
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>; public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary> /// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
public sealed record UpdateTemplateCommand( public sealed record UpdateTemplateCommand(
Guid TemplateId, Guid TemplateId,
string Name, string Name,
Guid? FallbackGroupId, Guid? FallbackGroupId,
Guid? DefaultJunctionId, Guid? DefaultJunctionId,
PlanningRules? Rules PlanningRules? Rules
) : ICommand<Result>; ) : ICommand<Result>;
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand> public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
{ {
public CreateLayerCommandValidator() public CreateLayerCommandValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(128); RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000); RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
} }
} }
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand> public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
{ {
public UpdateLayerCommandValidator() public UpdateLayerCommandValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(128); RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000); RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
} }
} }
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand> public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
{ {
public UpdateTemplateCommandValidator() public UpdateTemplateCommandValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история // Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
// всё равно ограничена глубиной хранения ленты. // всё равно ограничена глубиной хранения ленты.
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays) RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
.InclusiveBetween(1, 365) .InclusiveBetween(1, 365)
.When(x => x.Rules?.MaxRepeatsInWindow is not null); .When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max) RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
.InclusiveBetween(1, 1000) .InclusiveBetween(1, 1000)
.When(x => x.Rules?.MaxRepeatsInWindow is not null); .When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleForEach(x => x.Rules!.MaxAudienceByTime) RuleForEach(x => x.Rules!.MaxAudienceByTime)
.Must(window => window.From != window.To) .Must(window => window.From != window.To)
.WithMessage("Окно нулевой длины ничего не ограничивает.") .WithMessage("Окно нулевой длины ничего не ограничивает.")
.When(x => x.Rules?.MaxAudienceByTime is not null); .When(x => x.Rules?.MaxAudienceByTime is not null);
} }
} }
@@ -1,75 +1,75 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
namespace TeleWave.Application.Programming.Templates; namespace TeleWave.Application.Programming.Templates;
/// <summary> /// <summary>
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала /// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить /// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону. /// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
/// </summary> /// </summary>
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience) public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
{ {
public bool Contains(TimeOnly moment) => public bool Contains(TimeOnly moment) =>
From <= To ? moment >= From && moment < To : moment >= From || moment < To; From <= To ? moment >= From && moment < To : moment >= From || moment < To;
} }
/// <summary> /// <summary>
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают /// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость. /// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
/// ///
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени /// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу. /// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
/// </summary> /// </summary>
public sealed record PlanningRules( public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null, IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary> /// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null, RepeatLimitRule? MaxRepeatsInWindow = null,
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ── // ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary> /// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
int? MaxBreakMinutesPerHour = null, int? MaxBreakMinutesPerHour = null,
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary> /// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
int? MaxGenreSharePercent = null, int? MaxGenreSharePercent = null,
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary> /// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
int? MaxFallbackSharePercent = null int? MaxFallbackSharePercent = null
) )
{ {
private static readonly JsonSerializerOptions Options = new() private static readonly JsonSerializerOptions Options = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }, Converters = { new JsonStringEnumConverter() },
}; };
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary> /// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
public ShowAudience? AudienceAt(TimeOnly moment) public ShowAudience? AudienceAt(TimeOnly moment)
{ {
if (MaxAudienceByTime is not { Count: > 0 } windows) if (MaxAudienceByTime is not { Count: > 0 } windows)
return null; return null;
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно // Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
// отменяться более широким окном, случайно наложенным сверху. // отменяться более широким окном, случайно наложенным сверху.
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList(); var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
return applicable.Count > 0 ? applicable.Min() : null; return applicable.Count > 0 ? applicable.Min() : null;
} }
public string ToJson() => JsonSerializer.Serialize(this, Options); public string ToJson() => JsonSerializer.Serialize(this, Options);
public static PlanningRules? FromJson(string? json) public static PlanningRules? FromJson(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
return null; return null;
try try
{ {
return JsonSerializer.Deserialize<PlanningRules>(json, Options); return JsonSerializer.Deserialize<PlanningRules>(json, Options);
} }
catch (JsonException) catch (JsonException)
{ {
return null; return null;
} }
} }
} }
public sealed record RepeatLimitRule(int WindowDays, int Max); public sealed record RepeatLimitRule(int WindowDays, int Max);
@@ -1,59 +1,59 @@
using FluentValidation; using FluentValidation;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates; namespace TeleWave.Application.Programming.Templates;
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary> /// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
public sealed record SlotInput( public sealed record SlotInput(
string Title, string Title,
int? Weekday, int? Weekday,
TimeOnly TargetStart, TimeOnly TargetStart,
int TargetDurationMinutes, int TargetDurationMinutes,
Daypart Daypart, Daypart Daypart,
SlotKind SlotKind, SlotKind SlotKind,
Guid? GroupId, Guid? GroupId,
SlotStrategy? Strategy, SlotStrategy? Strategy,
RepeatSource? RepeatSource, RepeatSource? RepeatSource,
SlotBlockMode BlockMode, SlotBlockMode BlockMode,
int BlockValue, int BlockValue,
OverflowPolicy OverflowPolicy, OverflowPolicy OverflowPolicy,
bool IsAnchor, bool IsAnchor,
int MaxDriftMinutes, int MaxDriftMinutes,
int? SnapToMinutes, int? SnapToMinutes,
/// <summary>Стык между единицами внутри блока.</summary> /// <summary>Стык между единицами внутри блока.</summary>
Guid? JunctionBetweenId = null, Guid? JunctionBetweenId = null,
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary> /// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
Guid? JunctionAfterId = null Guid? JunctionAfterId = null
); );
public sealed class SlotInputValidator : AbstractValidator<SlotInput> public sealed class SlotInputValidator : AbstractValidator<SlotInput>
{ {
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary> /// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
private static readonly int[] AllowedSnap = [5, 10, 15, 30]; private static readonly int[] AllowedSnap = [5, 10, 15, 30];
public SlotInputValidator() public SlotInputValidator()
{ {
RuleFor(x => x.Title).NotEmpty().MaximumLength(256); RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null); RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя. // Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60); RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
RuleFor(x => x.BlockValue).GreaterThan(0); RuleFor(x => x.BlockValue).GreaterThan(0);
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60); RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
RuleFor(x => x.SnapToMinutes) RuleFor(x => x.SnapToMinutes)
.Must(value => value is null || AllowedSnap.Contains(value.Value)) .Must(value => value is null || AllowedSnap.Contains(value.Value))
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут."); .WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
RuleFor(x => x.Strategy!.CooldownDays) RuleFor(x => x.Strategy!.CooldownDays)
.InclusiveBetween(0, 3650) .InclusiveBetween(0, 3650)
.When(x => x.Strategy is not null); .When(x => x.Strategy is not null);
RuleFor(x => x.RepeatSource!.DaysAgo) RuleFor(x => x.RepeatSource!.DaysAgo)
.InclusiveBetween(1, 365) .InclusiveBetween(1, 365)
.When(x => x.RepeatSource is not null); .When(x => x.RepeatSource is not null);
RuleFor(x => x.RepeatSource!.DurationMinutes) RuleFor(x => x.RepeatSource!.DurationMinutes)
.InclusiveBetween(1, 24 * 60) .InclusiveBetween(1, 24 * 60)
.When(x => x.RepeatSource is not null); .When(x => x.RepeatSource is not null);
} }
} }
@@ -1,110 +1,110 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates; namespace TeleWave.Application.Programming.Templates;
/// <summary> /// <summary>
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что /// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
/// им нужны соседние слоты и справочник групп. /// им нужны соседние слоты и справочник групп.
/// </summary> /// </summary>
public sealed class SlotWriter(IAppDbContext dbContext) public sealed class SlotWriter(IAppDbContext dbContext)
{ {
/// <summary> /// <summary>
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед /// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
/// созданием. /// созданием.
/// </summary> /// </summary>
public async Task<Result> ApplyAsync( public async Task<Result> ApplyAsync(
GridLayer layer, GridLayer layer,
Slot? slot, Slot? slot,
SlotInput input, SlotInput input,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным. // Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
if ( if (
layer.HasOverlap( layer.HasOverlap(
input.Weekday, input.Weekday,
input.TargetStart, input.TargetStart,
input.TargetDurationMinutes, input.TargetDurationMinutes,
slot?.Id slot?.Id
) )
) )
return Result.Failure(TemplateErrors.SlotsOverlap); return Result.Failure(TemplateErrors.SlotsOverlap);
if (input.SlotKind == SlotKind.Content) if (input.SlotKind == SlotKind.Content)
{ {
if (input.GroupId is not { } groupId) if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.GroupRequired); return Result.Failure(TemplateErrors.GroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)) if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound); return Result.Failure(TemplateErrors.GroupNotFound);
} }
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null) if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
return Result.Failure(TemplateErrors.RepeatSourceRequired); return Result.Failure(TemplateErrors.RepeatSourceRequired);
var target = var target =
slot slot
?? layer.AddSlot( ?? layer.AddSlot(
input.Title, input.Title,
input.TargetStart, input.TargetStart,
input.TargetDurationMinutes, input.TargetDurationMinutes,
input.Daypart, input.Daypart,
input.SlotKind, input.SlotKind,
input.Weekday input.Weekday
); );
target.UpdateTiming( target.UpdateTiming(
input.Weekday, input.Weekday,
input.TargetStart, input.TargetStart,
input.TargetDurationMinutes, input.TargetDurationMinutes,
input.Daypart, input.Daypart,
input.IsAnchor, input.IsAnchor,
input.MaxDriftMinutes, input.MaxDriftMinutes,
input.SnapToMinutes input.SnapToMinutes
); );
target.UpdateContent( target.UpdateContent(
new SlotContent( new SlotContent(
input.Title, input.Title,
input.SlotKind, input.SlotKind,
input.GroupId, input.GroupId,
input.Strategy?.ToJson(), input.Strategy?.ToJson(),
input.RepeatSource?.ToJson(), input.RepeatSource?.ToJson(),
input.BlockMode, input.BlockMode,
input.BlockValue, input.BlockValue,
input.OverflowPolicy, input.OverflowPolicy,
input.JunctionBetweenId, input.JunctionBetweenId,
input.JunctionAfterId input.JunctionAfterId
) )
); );
return Result.Success(); return Result.Success();
} }
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary> /// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync( public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
Guid layerId, Guid layerId,
CancellationToken cancellationToken CancellationToken cancellationToken
) => ) =>
dbContext dbContext
.ScheduleTemplates.Include(t => t.Layers) .ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots) .ThenInclude(l => l.Slots)
.AsSplitQuery() .AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken); .FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
/// <summary>Загружает шаблон по слоту.</summary> /// <summary>Загружает шаблон по слоту.</summary>
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync( public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
Guid slotId, Guid slotId,
CancellationToken cancellationToken CancellationToken cancellationToken
) => ) =>
dbContext dbContext
.ScheduleTemplates.Include(t => t.Layers) .ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots) .ThenInclude(l => l.Slots)
.AsSplitQuery() .AsSplitQuery()
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)), t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
cancellationToken cancellationToken
); );
} }
@@ -1,54 +1,54 @@
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates; namespace TeleWave.Application.Programming.Templates;
public sealed record SlotDto( public sealed record SlotDto(
Guid Id, Guid Id,
Guid LayerId, Guid LayerId,
int? Weekday, int? Weekday,
TimeOnly TargetStart, TimeOnly TargetStart,
int TargetDurationMinutes, int TargetDurationMinutes,
string Title, string Title,
Daypart Daypart, Daypart Daypart,
SlotKind SlotKind, SlotKind SlotKind,
Guid? GroupId, Guid? GroupId,
string? GroupName, string? GroupName,
SlotStrategy? Strategy, SlotStrategy? Strategy,
RepeatSource? RepeatSource, RepeatSource? RepeatSource,
SlotBlockMode BlockMode, SlotBlockMode BlockMode,
int BlockValue, int BlockValue,
OverflowPolicy OverflowPolicy, OverflowPolicy OverflowPolicy,
bool IsAnchor, bool IsAnchor,
int MaxDriftMinutes, int MaxDriftMinutes,
int? SnapToMinutes, int? SnapToMinutes,
Guid? JunctionBetweenId, Guid? JunctionBetweenId,
Guid? JunctionAfterId Guid? JunctionAfterId
); );
public sealed record GridLayerDto( public sealed record GridLayerDto(
Guid Id, Guid Id,
string Name, string Name,
int Priority, int Priority,
bool IsEnabled, bool IsEnabled,
bool IsBackground, bool IsBackground,
LayerApplicability? Applicability, LayerApplicability? Applicability,
IReadOnlyList<SlotDto> Slots IReadOnlyList<SlotDto> Slots
); );
public sealed record ScheduleTemplateDto( public sealed record ScheduleTemplateDto(
Guid Id, Guid Id,
Guid ChannelId, Guid ChannelId,
string Name, string Name,
Guid? FallbackGroupId, Guid? FallbackGroupId,
Guid? DefaultJunctionId, Guid? DefaultJunctionId,
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary> /// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
PlanningRules? Rules, PlanningRules? Rules,
int Revision, int Revision,
int AppliedRevision, int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary> /// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges, bool HasPendingChanges,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary> /// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes, int UtcOffsetMinutes,
TimeOnly DayStartTime, TimeOnly DayStartTime,
IReadOnlyList<GridLayerDto> Layers IReadOnlyList<GridLayerDto> Layers
); );
@@ -1,66 +1,66 @@
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates; namespace TeleWave.Application.Programming.Templates;
public static class TemplateErrors public static class TemplateErrors
{ {
public static readonly Error NotFound = Error.NotFound( public static readonly Error NotFound = Error.NotFound(
"Templates.NotFound", "Templates.NotFound",
"Шаблон сетки не найден." "Шаблон сетки не найден."
); );
public static readonly Error LayerNotFound = Error.NotFound( public static readonly Error LayerNotFound = Error.NotFound(
"Templates.LayerNotFound", "Templates.LayerNotFound",
"Слой сетки не найден." "Слой сетки не найден."
); );
public static readonly Error SlotNotFound = Error.NotFound( public static readonly Error SlotNotFound = Error.NotFound(
"Templates.SlotNotFound", "Templates.SlotNotFound",
"Слот не найден." "Слот не найден."
); );
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation( public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
"Templates.BackgroundLayerCannotBeDeleted", "Templates.BackgroundLayerCannotBeDeleted",
"Фоновый слой удалить нельзя — без него в сетке появятся дыры." "Фоновый слой удалить нельзя — без него в сетке появятся дыры."
); );
public static readonly Error SlotsOverlap = Error.Conflict( public static readonly Error SlotsOverlap = Error.Conflict(
"Templates.SlotsOverlap", "Templates.SlotsOverlap",
"Слоты одного слоя пересекаются по времени." "Слоты одного слоя пересекаются по времени."
); );
public static readonly Error GroupRequired = Error.Validation( public static readonly Error GroupRequired = Error.Validation(
"Templates.GroupRequired", "Templates.GroupRequired",
"Слоту с контентом нужна группа." "Слоту с контентом нужна группа."
); );
public static readonly Error GroupNotFound = Error.NotFound( public static readonly Error GroupNotFound = Error.NotFound(
"Templates.GroupNotFound", "Templates.GroupNotFound",
"Группа не найдена." "Группа не найдена."
); );
public static readonly Error JunctionNotFound = Error.NotFound( public static readonly Error JunctionNotFound = Error.NotFound(
"Templates.JunctionNotFound", "Templates.JunctionNotFound",
"Шаблон стыка не найден." "Шаблон стыка не найден."
); );
public static readonly Error JunctionElementNotFound = Error.NotFound( public static readonly Error JunctionElementNotFound = Error.NotFound(
"Templates.JunctionElementNotFound", "Templates.JunctionElementNotFound",
"Врезка не найдена." "Врезка не найдена."
); );
public static readonly Error JunctionInUse = Error.Conflict( public static readonly Error JunctionInUse = Error.Conflict(
"Templates.JunctionInUse", "Templates.JunctionInUse",
"Стык используется слотами — сначала отвяжите его." "Стык используется слотами — сначала отвяжите его."
); );
public static readonly Error JunctionGroupRequired = Error.Validation( public static readonly Error JunctionGroupRequired = Error.Validation(
"Templates.JunctionGroupRequired", "Templates.JunctionGroupRequired",
"Врезке нужна группа, откуда брать ролики." "Врезке нужна группа, откуда брать ролики."
); );
public static readonly Error RepeatSourceRequired = Error.Validation( public static readonly Error RepeatSourceRequired = Error.Validation(
"Templates.RepeatSourceRequired", "Templates.RepeatSourceRequired",
"Слоту-повтору нужно указать, что повторять." "Слоту-повтору нужно указать, что повторять."
); );
} }
@@ -1,328 +1,328 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate; namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext) public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>> : IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{ {
private const int MinutesInDay = 24 * 60; private const int MinutesInDay = 24 * 60;
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle( public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
ValidateTemplateQuery query, ValidateTemplateQuery query,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext var channel = await dbContext
.Channels.AsNoTracking() .Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null) if (channel is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound); return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
var template = await dbContext var template = await dbContext
.ScheduleTemplates.AsNoTracking() .ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers) .Include(t => t.Layers)
.ThenInclude(l => l.Slots) .ThenInclude(l => l.Slots)
.AsSplitQuery() .AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken); .FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null) if (template is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound); return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
var layers = template.Layers.Where(l => l.IsEnabled).ToList(); var layers = template.Layers.Where(l => l.IsEnabled).ToList();
var groupIds = layers var groupIds = layers
.SelectMany(l => l.Slots) .SelectMany(l => l.Slots)
.Select(s => s.GroupId) .Select(s => s.GroupId)
.Where(id => id is not null) .Where(id => id is not null)
.Select(id => id!.Value) .Select(id => id!.Value)
.Distinct() .Distinct()
.ToList(); .ToList();
var groups = await dbContext var groups = await dbContext
.Groups.AsNoTracking() .Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id)) .Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount)) .Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken); .ToDictionaryAsync(g => g.Id, cancellationToken);
var rules = PlanningRules.FromJson(template.RulesJson); var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken); var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var dayStart = channel.DayStartTime; var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>(); var issues = new List<TemplateIssueDto>();
foreach (var layer in layers) foreach (var layer in layers)
{ {
issues.AddRange(FindOverlaps(layer, dayStart)); issues.AddRange(FindOverlaps(layer, dayStart));
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content)) foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules)); issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
} }
issues.AddRange(FindGaps(layers, dayStart)); issues.AddRange(FindGaps(layers, dayStart));
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues); return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
} }
private sealed record GroupStats(Guid Id, string Name, int ItemCount); private sealed record GroupStats(Guid Id, string Name, int ItemCount);
private static IEnumerable<TemplateIssueDto> CheckSlot( private static IEnumerable<TemplateIssueDto> CheckSlot(
GridLayer layer, GridLayer layer,
Slot slot, Slot slot,
IReadOnlyDictionary<Guid, GroupStats> groups, IReadOnlyDictionary<Guid, GroupStats> groups,
IReadOnlyDictionary<Guid, ShowAudience> strictest, IReadOnlyDictionary<Guid, ShowAudience> strictest,
PlanningRules? rules PlanningRules? rules
) )
{ {
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group)) if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
{ {
yield return new TemplateIssueDto( yield return new TemplateIssueDto(
TemplateIssueKind.GroupMissing, TemplateIssueKind.GroupMissing,
TemplateIssueSeverity.Error, TemplateIssueSeverity.Error,
layer.Id, layer.Id,
slot.Id, slot.Id,
$"У слота «{slot.Title}» не выбрана группа — место закроет фон." $"У слота «{slot.Title}» не выбрана группа — место закроет фон."
); );
yield break; yield break;
} }
if (group.ItemCount == 0) if (group.ItemCount == 0)
{ {
yield return new TemplateIssueDto( yield return new TemplateIssueDto(
TemplateIssueKind.GroupEmpty, TemplateIssueKind.GroupEmpty,
TemplateIssueSeverity.Error, TemplateIssueSeverity.Error,
layer.Id, layer.Id,
slot.Id, slot.Id,
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон." $"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
); );
yield break; yield break;
} }
var perWeek = OccurrencesPerWeek(slot); var perWeek = OccurrencesPerWeek(slot);
if (group.ItemCount < perWeek) if (group.ItemCount < perWeek)
yield return new TemplateIssueDto( yield return new TemplateIssueDto(
TemplateIssueKind.GroupTooSmall, TemplateIssueKind.GroupTooSmall,
TemplateIssueSeverity.Warning, TemplateIssueSeverity.Warning,
layer.Id, layer.Id,
slot.Id, slot.Id,
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — " $"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
+ "повторы пойдут чаще, чем раз в неделю." + "повторы пойдут чаще, чем раз в неделю."
); );
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз, // Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
// и если это больше состава группы, отсекать будет некого. // и если это больше состава группы, отсекать будет некого.
var strategy = SlotStrategy.FromJson(slot.StrategyJson); var strategy = SlotStrategy.FromJson(slot.StrategyJson);
if ( if (
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 } strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount && strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
) )
yield return new TemplateIssueDto( yield return new TemplateIssueDto(
TemplateIssueKind.CooldownUnreachable, TemplateIssueKind.CooldownUnreachable,
TemplateIssueSeverity.Warning, TemplateIssueSeverity.Warning,
layer.Id, layer.Id,
slot.Id, slot.Id,
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях " $"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
+ $"в группе «{group.Name}»." + $"в группе «{group.Name}»."
); );
if ( if (
rules?.AudienceAt(slot.TargetStart) is { } maxAudience rules?.AudienceAt(slot.TargetStart) is { } maxAudience
&& strictest.TryGetValue(groupId, out var groupAudience) && strictest.TryGetValue(groupId, out var groupAudience)
&& groupAudience > maxAudience && groupAudience > maxAudience
) )
yield return new TemplateIssueDto( yield return new TemplateIssueDto(
TemplateIssueKind.AudienceConflict, TemplateIssueKind.AudienceConflict,
TemplateIssueSeverity.Warning, TemplateIssueSeverity.Warning,
layer.Id, layer.Id,
slot.Id, slot.Id,
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот " $"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»." + $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
); );
} }
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary> /// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1; private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
/// <summary> /// <summary>
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый /// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
/// по времени, а второй молча пропадёт — это стоит показать до генерации. /// по времени, а второй молча пропадёт — это стоит показать до генерации.
/// </summary> /// </summary>
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart) private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
{ {
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList(); var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний, // Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
// и «телом» внешнего цикла его читает только компилятор. // и «телом» внешнего цикла его читает только компилятор.
for (var i = 0; i < slots.Count; i++) for (var i = 0; i < slots.Count; i++)
{ {
for (var j = i + 1; j < slots.Count; j++) for (var j = i + 1; j < slots.Count; j++)
{ {
var a = slots[i]; var a = slots[i];
var b = slots[j]; var b = slots[j];
if (!SameDays(a, b)) if (!SameDays(a, b))
continue; continue;
var aFrom = OffsetInDay(a.TargetStart, dayStart); var aFrom = OffsetInDay(a.TargetStart, dayStart);
var bFrom = OffsetInDay(b.TargetStart, dayStart); var bFrom = OffsetInDay(b.TargetStart, dayStart);
if ( if (
aFrom < bFrom + b.TargetDurationMinutes aFrom < bFrom + b.TargetDurationMinutes
&& bFrom < aFrom + a.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes
) )
yield return new TemplateIssueDto( yield return new TemplateIssueDto(
TemplateIssueKind.SlotOverlap, TemplateIssueKind.SlotOverlap,
TemplateIssueSeverity.Warning, TemplateIssueSeverity.Warning,
layer.Id, layer.Id,
b.Id, b.Id,
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»." $"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
); );
} }
} }
} }
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary> /// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
private static bool SameDays(Slot a, Slot b) => private static bool SameDays(Slot a, Slot b) =>
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday; a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
/// <summary> /// <summary>
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели: /// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
/// дыра во вторник ночью не видна, если смотреть на неделю целиком. /// дыра во вторник ночью не видна, если смотреть на неделю целиком.
/// </summary> /// </summary>
private static IEnumerable<TemplateIssueDto> FindGaps( private static IEnumerable<TemplateIssueDto> FindGaps(
IReadOnlyList<GridLayer> layers, IReadOnlyList<GridLayer> layers,
TimeOnly dayStart TimeOnly dayStart
) )
{ {
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 }) foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
{ {
var intervals = layers var intervals = layers
.SelectMany(l => l.Slots) .SelectMany(l => l.Slots)
.Where(s => s.Weekday is null || s.Weekday == weekday) .Where(s => s.Weekday is null || s.Weekday == weekday)
.Select(s => .Select(s =>
{ {
var from = OffsetInDay(s.TargetStart, dayStart); var from = OffsetInDay(s.TargetStart, dayStart);
return (From: from, To: from + s.TargetDurationMinutes); return (From: from, To: from + s.TargetDurationMinutes);
}) })
.OrderBy(i => i.From) .OrderBy(i => i.From)
.ToList(); .ToList();
var cursor = 0; var cursor = 0;
foreach (var interval in intervals) foreach (var interval in intervals)
{ {
if (interval.From > cursor) if (interval.From > cursor)
yield return Gap(weekday, cursor, interval.From, dayStart); yield return Gap(weekday, cursor, interval.From, dayStart);
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay)); cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
} }
if (cursor < MinutesInDay) if (cursor < MinutesInDay)
yield return Gap(weekday, cursor, MinutesInDay, dayStart); yield return Gap(weekday, cursor, MinutesInDay, dayStart);
} }
} }
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart) private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
{ {
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday]; var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот. // Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
var interval = var interval =
to - from >= MinutesInDay to - from >= MinutesInDay
? "весь день" ? "весь день"
: $"{Clock(from, dayStart)}{Clock(to, dayStart)}"; : $"{Clock(from, dayStart)}{Clock(to, dayStart)}";
return new TemplateIssueDto( return new TemplateIssueDto(
TemplateIssueKind.GridGap, TemplateIssueKind.GridGap,
TemplateIssueSeverity.Error, TemplateIssueSeverity.Error,
null, null,
null, null,
$"Не покрыто: {day} {interval}." $"Не покрыто: {day} {interval}."
); );
} }
private static string Clock(int offsetMinutes, TimeOnly dayStart) private static string Clock(int offsetMinutes, TimeOnly dayStart)
{ {
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay; var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
return $"{minutes / 60:00}:{minutes % 60:00}"; return $"{minutes / 60:00}:{minutes % 60:00}";
} }
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart) private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
{ {
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes; var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
return diff >= 0 ? diff : diff + MinutesInDay; return diff >= 0 ? diff : diff + MinutesInDay;
} }
/// <summary> /// <summary>
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем. /// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не /// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем. /// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary> /// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync( private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds, IReadOnlyCollection<Guid> groupIds,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (groupIds.Count == 0) if (groupIds.Count == 0)
return []; return [];
var items = await dbContext var items = await dbContext
.GroupItems.AsNoTracking() .GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId)) .Where(i => groupIds.Contains(i.GroupId))
.Select(i => new .Select(i => new
{ {
i.GroupId, i.GroupId,
i.ElementKind, i.ElementKind,
i.ElementId, i.ElementId,
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var showIds = items var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show) .Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId) .Select(i => i.ElementId)
.ToList(); .ToList();
var collectionIds = items var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection) .Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId) .Select(i => i.ElementId)
.ToList(); .ToList();
var partsByCollection = await dbContext var partsByCollection = await dbContext
.CollectionItems.AsNoTracking() .CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId)) .Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => new { i.CollectionId, i.ShowId }) .Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева // Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов. // выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds var neededShowIds = showIds
.Concat(partsByCollection.Select(p => p.ShowId)) .Concat(partsByCollection.Select(p => p.ShowId))
.Distinct() .Distinct()
.ToList(); .ToList();
var audiences = await dbContext var audiences = await dbContext
.Shows.AsNoTracking() .Shows.AsNoTracking()
.Where(s => neededShowIds.Contains(s.Id)) .Where(s => neededShowIds.Contains(s.Id))
.Select(s => new { s.Id, s.Audience }) .Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken); .ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
var result = new Dictionary<Guid, ShowAudience>(); var result = new Dictionary<Guid, ShowAudience>();
foreach (var item in items) foreach (var item in items)
{ {
var candidates = var candidates =
item.ElementKind == GroupElementKind.Show item.ElementKind == GroupElementKind.Show
? [item.ElementId] ? [item.ElementId]
: partsByCollection : partsByCollection
.Where(p => p.CollectionId == item.ElementId) .Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId) .Select(p => p.ShowId)
.ToList(); .ToList();
foreach (var showId in candidates) foreach (var showId in candidates)
{ {
if (audiences.GetValueOrDefault(showId) is not { } audience) if (audiences.GetValueOrDefault(showId) is not { } audience)
continue; continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current) if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience; result[item.GroupId] = audience;
} }
} }
return result; return result;
} }
} }
@@ -1,19 +1,19 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Settings.GetSiteSettings; namespace TeleWave.Application.Settings.GetSiteSettings;
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings) public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto> : IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
{ {
public async Task<SiteSettingsDto> Handle( public async Task<SiteSettingsDto> Handle(
GetSiteSettingsQuery query, GetSiteSettingsQuery query,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken); var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken); var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken); var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers); return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
} }
} }
@@ -1,16 +1,16 @@
namespace TeleWave.Application.Settings; namespace TeleWave.Application.Settings;
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary> /// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
public static class SettingKeys public static class SettingKeys
{ {
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary> /// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
public const string RegistrationEnabled = "registration.enabled"; public const string RegistrationEnabled = "registration.enabled";
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета /// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary> /// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
public const string PreferredAudioLanguages = "media.preferredAudioLanguages"; public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет). /// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
/// Сетка каналов остаётся вторым способом навигации всегда.</summary> /// Сетка каналов остаётся вторым способом навигации всегда.</summary>
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled"; public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
} }
@@ -1,9 +1,9 @@
namespace TeleWave.Application.Settings; namespace TeleWave.Application.Settings;
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary> /// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
public sealed record SiteSettingsDto( public sealed record SiteSettingsDto(
bool RegistrationEnabled, bool RegistrationEnabled,
string PreferredAudioLanguages, string PreferredAudioLanguages,
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary> /// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
bool ChannelNumbersEnabled bool ChannelNumbersEnabled
); );
@@ -1,10 +1,10 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings; namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed record UpdateSiteSettingsCommand( public sealed record UpdateSiteSettingsCommand(
bool RegistrationEnabled, bool RegistrationEnabled,
string PreferredAudioLanguages, string PreferredAudioLanguages,
bool ChannelNumbersEnabled bool ChannelNumbersEnabled
) : ICommand<Result>; ) : ICommand<Result>;
@@ -1,34 +1,34 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings; namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings) public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
: ICommandHandler<UpdateSiteSettingsCommand, Result> : ICommandHandler<UpdateSiteSettingsCommand, Result>
{ {
public async Task<Result> Handle( public async Task<Result> Handle(
UpdateSiteSettingsCommand command, UpdateSiteSettingsCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
// Сохранение выполняет UnitOfWorkBehavior команды. // Сохранение выполняет UnitOfWorkBehavior команды.
await siteSettings.SetRegistrationEnabledAsync( await siteSettings.SetRegistrationEnabledAsync(
command.RegistrationEnabled, command.RegistrationEnabled,
cancellationToken cancellationToken
); );
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем. // Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
var languages = string.Join( var languages = string.Join(
',', ',',
(command.PreferredAudioLanguages ?? string.Empty) (command.PreferredAudioLanguages ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => x.ToLowerInvariant()) .Select(x => x.ToLowerInvariant())
); );
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken); await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
await siteSettings.SetChannelNumbersEnabledAsync( await siteSettings.SetChannelNumbersEnabledAsync(
command.ChannelNumbersEnabled, command.ChannelNumbersEnabled,
cancellationToken cancellationToken
); );
return Result.Success(); return Result.Success();
} }
} }
@@ -1,93 +1,93 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming.ListPublicChannels; namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext) public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>> : IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
{ {
public async Task<IReadOnlyList<PublicChannelDto>> Handle( public async Task<IReadOnlyList<PublicChannelDto>> Handle(
ListPublicChannelsQuery query, ListPublicChannelsQuery query,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channels = await dbContext var channels = await dbContext
.Channels.AsNoTracking() .Channels.AsNoTracking()
.Where(c => c.IsEnabled) .Where(c => c.IsEnabled)
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь // Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
// разрешает ничью между ненумерованными. // разрешает ничью между ненумерованными.
.OrderBy(c => c.Number == null) .OrderBy(c => c.Number == null)
.ThenBy(c => c.Number) .ThenBy(c => c.Number)
.ThenBy(c => c.Name) .ThenBy(c => c.Name)
.Select(c => new .Select(c => new
{ {
c.Id, c.Id,
c.Slug, c.Slug,
c.Name, c.Name,
c.Number, c.Number,
c.LogoImageId, c.LogoImageId,
c.LogoCorner, c.LogoCorner,
c.LogoOpacity, c.LogoOpacity,
c.ShowClock, c.ShowClock,
c.AnalogFilterStrength, c.AnalogFilterStrength,
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (channels.Count == 0) if (channels.Count == 0)
return []; return [];
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList(); var channelIds = channels.Select(c => c.Id).ToList();
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки. // Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
var currentByChannel = await dbContext var currentByChannel = await dbContext
.ScheduleEntries.AsNoTracking() .ScheduleEntries.AsNoTracking()
.Where(e => .Where(e =>
channelIds.Contains(e.ChannelId) channelIds.Contains(e.ChannelId)
&& e.Kind == ScheduleEntryKind.Program && e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now && e.StartsAtUtc <= now
&& e.EndsAtUtc > now && e.EndsAtUtc > now
&& e.ShowId != null && e.ShowId != null
) )
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value }) .Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var currentShowByChannel = currentByChannel var currentShowByChannel = currentByChannel
.GroupBy(x => x.ChannelId) .GroupBy(x => x.ChannelId)
.ToDictionary(g => g.Key, g => g.First().ShowId); .ToDictionary(g => g.Key, g => g.First().ShowId);
var showIds = currentShowByChannel.Values.Distinct().ToList(); var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext var shows = await dbContext
.Shows.AsNoTracking() .Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id)) .Where(s => showIds.Contains(s.Id))
.Select(s => new .Select(s => new
{ {
s.Id, s.Id,
s.Name, s.Name,
s.PosterImageId, s.PosterImageId,
}) })
.ToDictionaryAsync(s => s.Id, cancellationToken); .ToDictionaryAsync(s => s.Id, cancellationToken);
return channels return channels
.Select(c => .Select(c =>
{ {
var showId = currentShowByChannel.GetValueOrDefault(c.Id); var showId = currentShowByChannel.GetValueOrDefault(c.Id);
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null; var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
return new PublicChannelDto( return new PublicChannelDto(
c.Id, c.Id,
c.Slug, c.Slug,
c.Name, c.Name,
c.Number, c.Number,
show is null ? null : showId, show is null ? null : showId,
show?.Name, show?.Name,
show?.PosterImageId, show?.PosterImageId,
c.LogoImageId, c.LogoImageId,
c.LogoCorner, c.LogoCorner,
c.LogoOpacity, c.LogoOpacity,
c.ShowClock, c.ShowClock,
c.AnalogFilterStrength c.AnalogFilterStrength
); );
}) })
.ToList(); .ToList();
} }
} }
@@ -1,42 +1,42 @@
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming; namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto( public sealed record PublicChannelDto(
Guid Id, Guid Id,
string Slug, string Slug,
string Name, string Name,
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary> /// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
int? Number, int? Number,
Guid? CurrentShowId, Guid? CurrentShowId,
string? CurrentShowName, string? CurrentShowName,
Guid? CurrentShowPosterImageId, Guid? CurrentShowPosterImageId,
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary> /// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
Guid? LogoImageId, Guid? LogoImageId,
LogoCorner LogoCorner, LogoCorner LogoCorner,
double LogoOpacity, double LogoOpacity,
bool ShowClock, bool ShowClock,
double AnalogFilterStrength double AnalogFilterStrength
); );
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary> /// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
public sealed record PublicEpgEntryDto( public sealed record PublicEpgEntryDto(
ScheduleEntryKind Kind, ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc, DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc, DateTimeOffset EndsAtUtc,
Guid? ShowId, Guid? ShowId,
string? ShowName, string? ShowName,
Guid? ShowPosterImageId, Guid? ShowPosterImageId,
Guid? EpisodeId, Guid? EpisodeId,
string? EpisodeTitle, string? EpisodeTitle,
string? EpisodeOverview, string? EpisodeOverview,
Guid? EpisodeStillImageId Guid? EpisodeStillImageId
); );
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity); public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylistDto( public sealed record LivePlaylistDto(
long MediaSequence, long MediaSequence,
int TargetDuration, int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments IReadOnlyList<LiveSegmentDto> Segments
); );
+179 -179
View File
@@ -1,179 +1,179 @@
namespace TeleWave.Domain.Broadcast; namespace TeleWave.Domain.Broadcast;
/// <summary> /// <summary>
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>, /// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный /// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
/// филлер и общие настройки заставок. /// филлер и общие настройки заставок.
/// </summary> /// </summary>
public class Channel public class Channel
{ {
private readonly List<BumperTemplate> _bumperTemplates = []; private readonly List<BumperTemplate> _bumperTemplates = [];
public Guid Id { get; private set; } public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty; public string Name { get; private set; } = string.Empty;
public string Slug { get; private set; } = string.Empty; public string Slug { get; private set; } = string.Empty;
public bool IsEnabled { get; private set; } public bool IsEnabled { get; private set; }
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary> /// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { get; private set; } public DateTimeOffset EpochUtc { get; private set; }
/// <summary> /// <summary>
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам /// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
/// включается глобальным флагом настроек сайта; null — номер не задан. /// включается глобальным флагом настроек сайта; null — номер не задан.
/// </summary> /// </summary>
public int? Number { get; private set; } public int? Number { get; private set; }
/// <summary> /// <summary>
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный /// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке /// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA. /// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
/// </summary> /// </summary>
public int UtcOffsetMinutes { get; private set; } public int UtcOffsetMinutes { get; private set; }
/// <summary> /// <summary>
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00 /// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница. /// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
/// </summary> /// </summary>
public TimeOnly DayStartTime { get; private set; } public TimeOnly DayStartTime { get; private set; }
/// <summary>Активный шаблон сетки канала (один на канал).</summary> /// <summary>Активный шаблон сетки канала (один на канал).</summary>
public Guid? TemplateId { get; private set; } public Guid? TemplateId { get; private set; }
public const int DefaultUtcOffsetMinutes = 180; public const int DefaultUtcOffsetMinutes = 180;
public static readonly TimeOnly DefaultDayStartTime = new(6, 0); public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями) // ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
// живут в элементах стыка; на канале осталось только общее для всех заставок. ── // живут в элементах стыка; на канале осталось только общее для всех заставок. ──
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary> /// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
public bool BumpersEnabled { get; private set; } public bool BumpersEnabled { get; private set; }
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary> /// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; } public BumperSelection BumperSelection { get; private set; }
public BumperFont BumperFont { get; private set; } public BumperFont BumperFont { get; private set; }
private const string DefaultTemplateName = "Заставка 1"; private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary> /// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; } public Guid? FillerAssetId { get; private set; }
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает, // ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ── // и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
/// <summary>Логотип-оверлей: ссылка на реестр изображений или null (логотипа нет).</summary> /// <summary>Логотип-оверлей: ссылка на реестр изображений или null (логотипа нет).</summary>
public Guid? LogoImageId { get; private set; } public Guid? LogoImageId { get; private set; }
public LogoCorner LogoCorner { get; private set; } public LogoCorner LogoCorner { get; private set; }
/// <summary>Прозрачность логотипа, 0..1.</summary> /// <summary>Прозрачность логотипа, 0..1.</summary>
public double LogoOpacity { get; private set; } = 0.8; public double LogoOpacity { get; private set; } = 0.8;
/// <summary>Показывать ли часы поверх картинки.</summary> /// <summary>Показывать ли часы поверх картинки.</summary>
public bool ShowClock { get; private set; } public bool ShowClock { get; private set; }
/// <summary>Сила аналогового фильтра, 0..1 (0 — выключен).</summary> /// <summary>Сила аналогового фильтра, 0..1 (0 — выключен).</summary>
public double AnalogFilterStrength { get; private set; } public double AnalogFilterStrength { get; private set; }
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary> /// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates; public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
private Channel() { } private Channel() { }
public static Channel Create(string name, string slug, DateTimeOffset epochUtc) public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
{ {
var channel = new Channel var channel = new Channel
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = name, Name = name,
Slug = slug, Slug = slug,
IsEnabled = true, IsEnabled = true,
EpochUtc = epochUtc, EpochUtc = epochUtc,
BumpersEnabled = false, BumpersEnabled = false,
BumperSelection = BumperSelection.WeightedRandom, BumperSelection = BumperSelection.WeightedRandom,
BumperFont = BumperFont.Sans, BumperFont = BumperFont.Sans,
LogoOpacity = 0.8, LogoOpacity = 0.8,
UtcOffsetMinutes = DefaultUtcOffsetMinutes, UtcOffsetMinutes = DefaultUtcOffsetMinutes,
DayStartTime = DefaultDayStartTime, DayStartTime = DefaultDayStartTime,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл). // На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName)); channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
return channel; return channel;
} }
public void UpdateSettings( public void UpdateSettings(
string name, string name,
bool isEnabled, bool isEnabled,
bool bumpersEnabled, bool bumpersEnabled,
Guid? fillerAssetId Guid? fillerAssetId
) )
{ {
Name = name; Name = name;
IsEnabled = isEnabled; IsEnabled = isEnabled;
BumpersEnabled = bumpersEnabled; BumpersEnabled = bumpersEnabled;
FillerAssetId = fillerAssetId; FillerAssetId = fillerAssetId;
} }
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary> /// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
public void UpdateBumperSettings(BumperFont font, BumperSelection selection) public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
{ {
BumperFont = font; BumperFont = font;
BumperSelection = selection; BumperSelection = selection;
} }
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary> /// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
public void UpdateViewerSettings( public void UpdateViewerSettings(
Guid? logoImageId, Guid? logoImageId,
LogoCorner logoCorner, LogoCorner logoCorner,
double logoOpacity, double logoOpacity,
bool showClock, bool showClock,
double analogFilterStrength double analogFilterStrength
) )
{ {
LogoImageId = logoImageId; LogoImageId = logoImageId;
LogoCorner = logoCorner; LogoCorner = logoCorner;
LogoOpacity = Math.Clamp(logoOpacity, 0.0, 1.0); LogoOpacity = Math.Clamp(logoOpacity, 0.0, 1.0);
ShowClock = showClock; ShowClock = showClock;
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0); AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
} }
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary> /// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
public BumperTemplate AddBumperTemplate(string name) public BumperTemplate AddBumperTemplate(string name)
{ {
var nextPosition = var nextPosition =
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1; _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
var template = BumperTemplate.Create(Id, nextPosition, name); var template = BumperTemplate.Create(Id, nextPosition, name);
_bumperTemplates.Add(template); _bumperTemplates.Add(template);
return template; return template;
} }
public BumperTemplate? FindBumperTemplate(Guid templateId) => public BumperTemplate? FindBumperTemplate(Guid templateId) =>
_bumperTemplates.FirstOrDefault(t => t.Id == templateId); _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary> /// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
public bool RemoveBumperTemplate(Guid templateId) public bool RemoveBumperTemplate(Guid templateId)
{ {
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId); var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
if (template is null || template.IsDefault) if (template is null || template.IsDefault)
return false; return false;
_bumperTemplates.Remove(template); _bumperTemplates.Remove(template);
return true; return true;
} }
/// <summary>Привязать активный шаблон сетки.</summary> /// <summary>Привязать активный шаблон сетки.</summary>
public void SetTemplate(Guid? templateId) => TemplateId = templateId; public void SetTemplate(Guid? templateId) => TemplateId = templateId;
/// <summary> /// <summary>
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение /// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём. /// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
/// </summary> /// </summary>
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime) public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
{ {
Number = number is > 0 ? number : null; Number = number is > 0 ? number : null;
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60); UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
DayStartTime = dayStartTime; DayStartTime = dayStartTime;
} }
} }
@@ -1,123 +1,123 @@
namespace TeleWave.Domain.Broadcast; namespace TeleWave.Domain.Broadcast;
/// <summary> /// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и /// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей). /// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary> /// </summary>
public class ScheduleEntry public class ScheduleEntry
{ {
public Guid Id { get; private set; } public Guid Id { get; private set; }
public Guid ChannelId { get; private set; } public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; } public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; } public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; } public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; } public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary> /// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; } public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary> /// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; } public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary> /// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; } public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary> /// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
public Guid? SlotId { get; private set; } public Guid? SlotId { get; private set; }
/// <summary> /// <summary>
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же /// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку. /// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
/// </summary> /// </summary>
public Guid? CollectionId { get; private set; } public Guid? CollectionId { get; private set; }
/// <summary> /// <summary>
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент /// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается /// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
/// в угадывание. /// в угадывание.
/// </summary> /// </summary>
public string? TraceJson { get; private set; } public string? TraceJson { get; private set; }
private ScheduleEntry() { } private ScheduleEntry() { }
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary> /// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
public static ScheduleEntry FromSlot( public static ScheduleEntry FromSlot(
Guid channelId, Guid channelId,
Guid mediaAssetId, Guid mediaAssetId,
ScheduleEntryKind kind, ScheduleEntryKind kind,
DateTimeOffset startsAtUtc, DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc, DateTimeOffset endsAtUtc,
ScheduleEntryOrigin origin ScheduleEntryOrigin origin
) => ) =>
new() new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ChannelId = channelId, ChannelId = channelId,
MediaAssetId = mediaAssetId, MediaAssetId = mediaAssetId,
Kind = kind, Kind = kind,
StartsAtUtc = startsAtUtc, StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc, EndsAtUtc = endsAtUtc,
ShowId = origin.ShowId, ShowId = origin.ShowId,
EpisodeIndex = origin.EpisodeIndex, EpisodeIndex = origin.EpisodeIndex,
SlotId = origin.SlotId, SlotId = origin.SlotId,
TraceJson = origin.TraceJson, TraceJson = origin.TraceJson,
CollectionId = origin.CollectionId, CollectionId = origin.CollectionId,
}; };
public static ScheduleEntry Program( public static ScheduleEntry Program(
Guid channelId, Guid channelId,
Guid mediaAssetId, Guid mediaAssetId,
DateTimeOffset startsAtUtc, DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc, DateTimeOffset endsAtUtc,
Guid showId, Guid showId,
int episodeIndex int episodeIndex
) => ) =>
new() new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ChannelId = channelId, ChannelId = channelId,
MediaAssetId = mediaAssetId, MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program, Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc, StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc, EndsAtUtc = endsAtUtc,
ShowId = showId, ShowId = showId,
EpisodeIndex = episodeIndex, EpisodeIndex = episodeIndex,
}; };
public static ScheduleEntry Ad( public static ScheduleEntry Ad(
Guid channelId, Guid channelId,
Guid mediaAssetId, Guid mediaAssetId,
DateTimeOffset startsAtUtc, DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc DateTimeOffset endsAtUtc
) => ) =>
new() new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ChannelId = channelId, ChannelId = channelId,
MediaAssetId = mediaAssetId, MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad, Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc, StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc, EndsAtUtc = endsAtUtc,
}; };
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary> /// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
public static ScheduleEntry Bumper( public static ScheduleEntry Bumper(
Guid channelId, Guid channelId,
Guid mediaAssetId, Guid mediaAssetId,
DateTimeOffset startsAtUtc, DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc, DateTimeOffset endsAtUtc,
Guid? showId, Guid? showId,
Guid? bumperVariantId Guid? bumperVariantId
) => ) =>
new() new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ChannelId = channelId, ChannelId = channelId,
MediaAssetId = mediaAssetId, MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Bumper, Kind = ScheduleEntryKind.Bumper,
StartsAtUtc = startsAtUtc, StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc, EndsAtUtc = endsAtUtc,
ShowId = showId, ShowId = showId,
BumperVariantId = bumperVariantId, BumperVariantId = bumperVariantId,
}; };
} }
+176 -176
View File
@@ -1,176 +1,176 @@
namespace TeleWave.Domain.Library; namespace TeleWave.Domain.Library;
/// <summary> /// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка. /// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает /// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет /// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой. /// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
/// </summary> /// </summary>
public class Show public class Show
{ {
private readonly List<ShowEpisode> _episodes = []; private readonly List<ShowEpisode> _episodes = [];
private readonly List<ShowGenre> _genres = []; private readonly List<ShowGenre> _genres = [];
public Guid Id { get; private set; } public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty; public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах /// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary> /// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; } public string? OriginalName { get; private set; }
public string? Description { get; private set; } public string? Description { get; private set; }
public ShowKind Kind { get; private set; } public ShowKind Kind { get; private set; }
/// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную. /// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
/// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary> /// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
public ShowAudience? Audience { get; private set; } public ShowAudience? Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ── // ── Метаданные (TMDb/OMDb/вручную) ──
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary> /// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
public string? MetadataProvider { get; private set; } public string? MetadataProvider { get; private set; }
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary> /// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
public string? MetadataExternalId { get; private set; } public string? MetadataExternalId { get; private set; }
public int? Year { get; private set; } public int? Year { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary> /// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; } public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>; /// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary> /// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes; public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary> /// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres; public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { } private Show() { }
public static Show Create( public static Show Create(
string name, string name,
ShowKind kind, ShowKind kind,
string? description = null, string? description = null,
string? originalName = null, string? originalName = null,
ShowAudience? audience = null ShowAudience? audience = null
) => ) =>
new() new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = name, Name = name,
OriginalName = Normalize(originalName), OriginalName = Normalize(originalName),
Kind = kind, Kind = kind,
Description = description, Description = description,
Audience = audience, Audience = audience,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
/// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</summary> /// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</summary>
public void SetAudience(ShowAudience? audience) => Audience = audience; public void SetAudience(ShowAudience? audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary> /// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId; public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
/// <summary> /// <summary>
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным /// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке — /// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
/// так шоу с жанрами никогда не остаётся без основного. /// так шоу с жанрами никогда не остаётся без основного.
/// </summary> /// </summary>
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null) public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
{ {
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList(); var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
_genres.Clear(); _genres.Clear();
if (ids.Count == 0) if (ids.Count == 0)
return; return;
var primary = var primary =
primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0]; primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
foreach (var id in ids) foreach (var id in ids)
_genres.Add(ShowGenre.Create(Id, id, id == primary)); _genres.Add(ShowGenre.Create(Id, id, id == primary));
} }
public void Rename(string name, string? description) public void Rename(string name, string? description)
{ {
Name = name; Name = name;
Description = description; Description = description;
} }
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary> /// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
public void SetName(string name) => Name = name; public void SetName(string name) => Name = name;
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary> /// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName); public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия /// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее /// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary> /// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId) public ShowEpisode AddEpisode(Guid mediaAssetId)
{ {
if (!CanAddEpisode) if (!CanAddEpisode)
throw new InvalidOperationException( throw new InvalidOperationException(
"Только сериал может содержать больше одной серии." "Только сериал может содержать больше одной серии."
); );
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1; var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition); var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
_episodes.Add(episode); _episodes.Add(episode);
return episode; return episode;
} }
public bool RemoveEpisode(Guid episodeId) public bool RemoveEpisode(Guid episodeId)
{ {
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId); var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode is null) if (episode is null)
return false; return false;
_episodes.Remove(episode); _episodes.Remove(episode);
return true; return true;
} }
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно /// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary> /// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0; public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary> /// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata( public void ApplyMetadata(
string provider, string provider,
string externalId, string externalId,
string? description, string? description,
int? year, int? year,
Guid? posterImageId Guid? posterImageId
) )
{ {
MetadataProvider = provider; MetadataProvider = provider;
MetadataExternalId = externalId; MetadataExternalId = externalId;
if (!string.IsNullOrWhiteSpace(description)) if (!string.IsNullOrWhiteSpace(description))
Description = description; Description = description;
Year = year; Year = year;
if (posterImageId is not null) if (posterImageId is not null)
PosterImageId = posterImageId; PosterImageId = posterImageId;
} }
/// <summary>Ручная правка метаданных (без внешнего источника).</summary> /// <summary>Ручная правка метаданных (без внешнего источника).</summary>
public void UpdateMetadataManual(string? description, int? year) public void UpdateMetadataManual(string? description, int? year)
{ {
MetadataProvider = "manual"; MetadataProvider = "manual";
MetadataExternalId = null; MetadataExternalId = null;
Description = description; Description = description;
Year = year; Year = year;
} }
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary> /// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId; public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary> /// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata() public void ClearMetadata()
{ {
MetadataProvider = null; MetadataProvider = null;
MetadataExternalId = null; MetadataExternalId = null;
Year = null; Year = null;
PosterImageId = null; PosterImageId = null;
Description = null; Description = null;
} }
} }
@@ -1,21 +1,21 @@
namespace TeleWave.Domain.Media; namespace TeleWave.Domain.Media;
/// <summary>Откуда файл попал в хранилище.</summary> /// <summary>Откуда файл попал в хранилище.</summary>
public enum MediaSource public enum MediaSource
{ {
/// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary> /// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary>
Upload, Upload,
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary> /// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
Inbox, Inbox,
/// <summary> /// <summary>
/// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит /// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит
/// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу. /// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу.
/// </summary> /// </summary>
ManualInbox = 3, ManualInbox = 3,
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком. /// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary> /// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
Generated, Generated,
} }
@@ -1,221 +1,221 @@
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
namespace TeleWave.Domain.Programming.Planning; namespace TeleWave.Domain.Programming.Planning;
/// <summary> /// <summary>
/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не /// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не
/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям. /// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям.
/// </summary> /// </summary>
public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex); public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex);
/// <summary> /// <summary>
/// Элемент группы, развёрнутый в последовательность единиц. <see cref="LastPlayedUtc"/> — когда он /// Элемент группы, развёрнутый в последовательность единиц. <see cref="LastPlayedUtc"/> — когда он
/// в последний раз выходил в этом канале; по нему работает остывание. /// в последний раз выходил в этом канале; по нему работает остывание.
/// </summary> /// </summary>
public sealed record PlanningElement( public sealed record PlanningElement(
GroupElementKind Kind, GroupElementKind Kind,
Guid ElementId, Guid ElementId,
int Weight, int Weight,
int Position, int Position,
IReadOnlyList<PlanningUnit> Units, IReadOnlyList<PlanningUnit> Units,
DateTimeOffset? LastPlayedUtc = null, DateTimeOffset? LastPlayedUtc = null,
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary> /// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
ShowAudience? Audience = null, ShowAudience? Audience = null,
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary> /// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
); );
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary> /// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
public sealed record RepeatLimit(int WindowDays, int Max); public sealed record RepeatLimit(int WindowDays, int Max);
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary> /// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
public sealed record PlanningStrategy( public sealed record PlanningStrategy(
SlotStrategyKind Kind, SlotStrategyKind Kind,
bool RestartOnEnd = true, bool RestartOnEnd = true,
int CooldownDays = 0, int CooldownDays = 0,
bool IgnoreCooldownWhenExhausted = false, bool IgnoreCooldownWhenExhausted = false,
Guid? FixedElementId = null Guid? FixedElementId = null
); );
/// <summary>Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.</summary> /// <summary>Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.</summary>
public enum SlotStrategyKind public enum SlotStrategyKind
{ {
Sequential = 0, Sequential = 0,
RandomWithCooldown = 1, RandomWithCooldown = 1,
Fixed = 2, Fixed = 2,
} }
/// <summary>Где остановились в текущем элементе на момент начала прогона.</summary> /// <summary>Где остановились в текущем элементе на момент начала прогона.</summary>
public sealed record PlanningCursor( public sealed record PlanningCursor(
GroupElementKind? ElementKind, GroupElementKind? ElementKind,
Guid? ElementId, Guid? ElementId,
int NextUnitIndex int NextUnitIndex
); );
/// <summary> /// <summary>
/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают /// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают
/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC. /// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC.
/// </summary> /// </summary>
public sealed record PlanningSlot( public sealed record PlanningSlot(
Guid SlotId, Guid SlotId,
DateTimeOffset TargetStartUtc, DateTimeOffset TargetStartUtc,
int TargetDurationMinutes, int TargetDurationMinutes,
SlotKind SlotKind, SlotKind SlotKind,
bool IsAnchor, bool IsAnchor,
int MaxDriftMinutes, int MaxDriftMinutes,
int? SnapToMinutes, int? SnapToMinutes,
SlotBlockMode BlockMode, SlotBlockMode BlockMode,
int BlockValue, int BlockValue,
OverflowPolicy OverflowPolicy, OverflowPolicy OverflowPolicy,
PlanningStrategy Strategy, PlanningStrategy Strategy,
IReadOnlyList<PlanningElement> Elements, IReadOnlyList<PlanningElement> Elements,
PlanningCursor? Cursor, PlanningCursor? Cursor,
/// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary> /// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary>
IReadOnlyList<PlanningUnit>? RepeatUnits = null, IReadOnlyList<PlanningUnit>? RepeatUnits = null,
/// <summary>Врезки между единицами внутри блока.</summary> /// <summary>Врезки между единицами внутри блока.</summary>
PlanningJunction? JunctionBetween = null, PlanningJunction? JunctionBetween = null,
/// <summary>Врезки в конце блока.</summary> /// <summary>Врезки в конце блока.</summary>
PlanningJunction? JunctionAfter = null, PlanningJunction? JunctionAfter = null,
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary> /// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
ShowAudience? MaxAudience = null, ShowAudience? MaxAudience = null,
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary> /// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
RepeatLimit? RepeatLimit = null RepeatLimit? RepeatLimit = null
) )
{ {
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes); public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
} }
/// <summary> /// <summary>
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся /// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
/// решить, сколько их поставить и влезают ли они. /// решить, сколько их поставить и влезают ли они.
/// </summary> /// </summary>
public sealed record PlanningJunctionElement( public sealed record PlanningJunctionElement(
JunctionElementKind Kind, JunctionElementKind Kind,
IReadOnlyList<PlanningUnit> Units, IReadOnlyList<PlanningUnit> Units,
JunctionAmountMode AmountMode, JunctionAmountMode AmountMode,
int AmountValue, int AmountValue,
bool IsRequired, bool IsRequired,
/// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary> /// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary>
bool OnlyOnElementChange = false, bool OnlyOnElementChange = false,
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary> /// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
int MinMinutesBetween = 0, int MinMinutesBetween = 0,
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary> /// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
Guid? BumperTemplateId = null, Guid? BumperTemplateId = null,
/// <summary>Длительность резерва под заставку.</summary> /// <summary>Длительность резерва под заставку.</summary>
TimeSpan BumperDuration = default TimeSpan BumperDuration = default
); );
/// <summary>Стык: последовательность врезок между программами.</summary> /// <summary>Стык: последовательность врезок между программами.</summary>
public sealed record PlanningJunction( public sealed record PlanningJunction(
Guid JunctionId, Guid JunctionId,
IReadOnlyList<PlanningJunctionElement> Elements IReadOnlyList<PlanningJunctionElement> Elements
); );
/// <summary>Полный вход одного прогона генератора.</summary> /// <summary>Полный вход одного прогона генератора.</summary>
public sealed record PlanningInput( public sealed record PlanningInput(
Guid ChannelId, Guid ChannelId,
DateTimeOffset StartUtc, DateTimeOffset StartUtc,
DateTimeOffset HorizonEndUtc, DateTimeOffset HorizonEndUtc,
IReadOnlyList<PlanningSlot> Slots, IReadOnlyList<PlanningSlot> Slots,
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary> /// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
IReadOnlyList<PlanningUnit> FallbackUnits, IReadOnlyList<PlanningUnit> FallbackUnits,
int SegmentSeconds int SegmentSeconds
); );
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary> /// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
public sealed record PlannedItem( public sealed record PlannedItem(
Guid MediaAssetId, Guid MediaAssetId,
DateTimeOffset StartsAtUtc, DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc, DateTimeOffset EndsAtUtc,
Guid? ShowId, Guid? ShowId,
int? UnitIndex, int? UnitIndex,
Guid? SlotId, Guid? SlotId,
PlannedItemKind Kind, PlannedItemKind Kind,
PlanTrace? Trace = null, PlanTrace? Trace = null,
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary> /// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
Guid? BumperTemplateId = null, Guid? BumperTemplateId = null,
Guid? FromShowId = null, Guid? FromShowId = null,
Guid? ToShowId = null, Guid? ToShowId = null,
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary> /// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
Guid? CollectionId = null Guid? CollectionId = null
); );
public enum PlannedItemKind public enum PlannedItemKind
{ {
Program = 0, Program = 0,
Fallback = 1, Fallback = 1,
SignOff = 2, SignOff = 2,
Ad = 3, Ad = 3,
Promo = 4, Promo = 4,
/// <summary> /// <summary>
/// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента /// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента
/// собрана, — планировщик лишь резервирует под неё длительность. /// собрана, — планировщик лишь резервирует под неё длительность.
/// </summary> /// </summary>
Bumper = 5, Bumper = 5,
} }
/// <summary>Цепочка происхождения записи — питает экран «почему это здесь».</summary> /// <summary>Цепочка происхождения записи — питает экран «почему это здесь».</summary>
public sealed record PlanTrace( public sealed record PlanTrace(
Guid? SlotId, Guid? SlotId,
SlotKind SlotKind, SlotKind SlotKind,
GroupElementKind? ElementKind, GroupElementKind? ElementKind,
Guid? ElementId, Guid? ElementId,
SlotStrategyKind? Strategy, SlotStrategyKind? Strategy,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор без остывания).</summary> /// <summary>Сколько кандидатов осталось после остывания (null — выбор без остывания).</summary>
int? CandidatesAfterCooldown, int? CandidatesAfterCooldown,
/// <summary>Насколько фактический старт разошёлся с целевым, минуты.</summary> /// <summary>Насколько фактический старт разошёлся с целевым, минуты.</summary>
int DriftMinutes, int DriftMinutes,
/// <summary>Старт сдвинут вперёд округлением до круглого времени.</summary> /// <summary>Старт сдвинут вперёд округлением до круглого времени.</summary>
bool Snapped bool Snapped
); );
/// <summary>Новое состояние слота после прогона — оркестратор сохраняет его в БД.</summary> /// <summary>Новое состояние слота после прогона — оркестратор сохраняет его в БД.</summary>
public sealed record PlanningCursorUpdate( public sealed record PlanningCursorUpdate(
Guid SlotId, Guid SlotId,
GroupElementKind? ElementKind, GroupElementKind? ElementKind,
Guid? ElementId, Guid? ElementId,
int NextUnitIndex int NextUnitIndex
); );
/// <summary>Результат прогона: лента, новые курсоры и предупреждения для админа.</summary> /// <summary>Результат прогона: лента, новые курсоры и предупреждения для админа.</summary>
public sealed record PlanningResult( public sealed record PlanningResult(
IReadOnlyList<PlannedItem> Items, IReadOnlyList<PlannedItem> Items,
IReadOnlyList<PlanningCursorUpdate> Cursors, IReadOnlyList<PlanningCursorUpdate> Cursors,
IReadOnlyList<PlanningWarning> Warnings IReadOnlyList<PlanningWarning> Warnings
); );
/// <summary>Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.</summary> /// <summary>Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.</summary>
public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details); public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details);
public enum PlanningWarningKind public enum PlanningWarningKind
{ {
/// <summary>Слот не дал контента — место закрыл фон.</summary> /// <summary>Слот не дал контента — место закрыл фон.</summary>
SlotEmpty = 0, SlotEmpty = 0,
/// <summary>Фактический старт ушёл дальше допуска.</summary> /// <summary>Фактический старт ушёл дальше допуска.</summary>
DriftExceeded = 1, DriftExceeded = 1,
/// <summary>Остывание отсекло всех кандидатов.</summary> /// <summary>Остывание отсекло всех кандидатов.</summary>
CooldownExhausted = 2, CooldownExhausted = 2,
/// <summary>Не нашлось, что повторить.</summary> /// <summary>Не нашлось, что повторить.</summary>
RepeatSourceEmpty = 3, RepeatSourceEmpty = 3,
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary> /// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
FallbackEmpty = 4, FallbackEmpty = 4,
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary> /// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
CandidatesFiltered = 5, CandidatesFiltered = 5,
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ── // ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
/// <summary>Врезок в часе больше заданного потолка.</summary> /// <summary>Врезок в часе больше заданного потолка.</summary>
BreakLimitExceeded = 6, BreakLimitExceeded = 6,
/// <summary>Доля одного жанра за сутки выше заданной.</summary> /// <summary>Доля одного жанра за сутки выше заданной.</summary>
GenreShareExceeded = 7, GenreShareExceeded = 7,
/// <summary>Фон занял больше эфира, чем считается нормой.</summary> /// <summary>Фон занял больше эфира, чем считается нормой.</summary>
FallbackShareExceeded = 8, FallbackShareExceeded = 8,
} }
@@ -1,465 +1,465 @@
using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Broadcast.Scheduling;
namespace TeleWave.Domain.Programming.Planning; namespace TeleWave.Domain.Programming.Planning;
/// <summary> /// <summary>
/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/> /// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/>
/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо. /// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо.
/// ///
/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по /// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по
/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым /// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым
/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени). /// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени).
/// </summary> /// </summary>
public static class SchedulePlanner public static class SchedulePlanner
{ {
private const int IterationBackstop = 100_000; private const int IterationBackstop = 100_000;
/// <summary> /// <summary>
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история /// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком /// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу. /// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
/// ///
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через /// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
/// границу слота. /// границу слота.
/// </summary> /// </summary>
private sealed class PlanningRun(PlanningInput input, IRandomSource random) private sealed class PlanningRun(PlanningInput input, IRandomSource random)
{ {
public PlanningInput Input { get; } = input; public PlanningInput Input { get; } = input;
public IRandomSource Random { get; } = random; public IRandomSource Random { get; } = random;
public List<PlannedItem> Items { get; } = []; public List<PlannedItem> Items { get; } = [];
public List<PlanningCursorUpdate> Cursors { get; } = []; public List<PlanningCursorUpdate> Cursors { get; } = [];
public List<PlanningWarning> Warnings { get; } = []; public List<PlanningWarning> Warnings { get; } = [];
public JunctionHistory Junctions { get; } = new(); public JunctionHistory Junctions { get; } = new();
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary> /// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
public Guid? PreviousShowId { get; set; } public Guid? PreviousShowId { get; set; }
} }
public static PlanningResult Plan(PlanningInput input, IRandomSource random) public static PlanningResult Plan(PlanningInput input, IRandomSource random)
{ {
var run = new PlanningRun(input, random); var run = new PlanningRun(input, random);
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList(); var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
var cursor = input.StartUtc; var cursor = input.StartUtc;
var iterations = 0; var iterations = 0;
for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++) for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
{ {
if (iterations++ > IterationBackstop) if (iterations++ > IterationBackstop)
break; break;
var slot = slots[i]; var slot = slots[i];
// Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего, // Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего,
// а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше. // а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше.
if (slot.TargetEndUtc <= cursor) if (slot.TargetEndUtc <= cursor)
continue; continue;
var nextAnchor = FindNextAnchor(slots, i + 1); var nextAnchor = FindNextAnchor(slots, i + 1);
cursor = OpenSlot(slot, cursor, run, out var trace); cursor = OpenSlot(slot, cursor, run, out var trace);
if (cursor >= input.HorizonEndUtc) if (cursor >= input.HorizonEndUtc)
break; break;
cursor = FillSlot(slot, cursor, nextAnchor, trace, run); cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
} }
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край // Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
// упрётся в дыру. // упрётся в дыру.
if (cursor < input.HorizonEndUtc) if (cursor < input.HorizonEndUtc)
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null); cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0) if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
run.Warnings.Add( run.Warnings.Add(
new PlanningWarning( new PlanningWarning(
PlanningWarningKind.FallbackEmpty, PlanningWarningKind.FallbackEmpty,
null, null,
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры." "Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
) )
); );
return new PlanningResult(run.Items, run.Cursors, run.Warnings); return new PlanningResult(run.Items, run.Cursors, run.Warnings);
} }
/// <summary> /// <summary>
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает /// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
/// фактический старт и заполняет трейс сведениями о дрейфе. /// фактический старт и заполняет трейс сведениями о дрейфе.
/// </summary> /// </summary>
private static DateTimeOffset OpenSlot( private static DateTimeOffset OpenSlot(
PlanningSlot slot, PlanningSlot slot,
DateTimeOffset cursor, DateTimeOffset cursor,
PlanningRun run, PlanningRun run,
out PlanTrace trace out PlanTrace trace
) )
{ {
var snapped = false; var snapped = false;
if (cursor < slot.TargetStartUtc) if (cursor < slot.TargetStartUtc)
{ {
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно, // До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени. // для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId); cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
} }
else if (slot.SnapToMinutes is { } snap && snap > 0) else if (slot.SnapToMinutes is { } snap && snap > 0)
{ {
// Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше // Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше
// начать в 19:47, чем девять минут крутить фон. // начать в 19:47, чем девять минут крутить фон.
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap)); var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes)) if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
{ {
var afterFill = FillWithFallback(cursor, target, run, slot.SlotId); var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
snapped = afterFill > cursor; snapped = afterFill > cursor;
cursor = afterFill; cursor = afterFill;
} }
} }
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes); var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
if (Math.Abs(drift) > slot.MaxDriftMinutes) if (Math.Abs(drift) > slot.MaxDriftMinutes)
run.Warnings.Add( run.Warnings.Add(
new PlanningWarning( new PlanningWarning(
PlanningWarningKind.DriftExceeded, PlanningWarningKind.DriftExceeded,
slot.SlotId, slot.SlotId,
$"Фактический старт разошёлся с целевым на {drift} мин." $"Фактический старт разошёлся с целевым на {drift} мин."
) )
); );
trace = new PlanTrace(slot.SlotId, slot.SlotKind, null, null, null, null, drift, snapped); trace = new PlanTrace(slot.SlotId, slot.SlotKind, null, null, null, null, drift, snapped);
return cursor; return cursor;
} }
/// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary> /// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary>
private static DateTimeOffset FillSlot( private static DateTimeOffset FillSlot(
PlanningSlot slot, PlanningSlot slot,
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset? nextAnchor, DateTimeOffset? nextAnchor,
PlanTrace trace, PlanTrace trace,
PlanningRun run PlanningRun run
) )
{ {
var limit = Min(run.Input.HorizonEndUtc, nextAnchor); var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
switch (slot.SlotKind) switch (slot.SlotKind)
{ {
case SlotKind.SignOff: case SlotKind.SignOff:
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо. // Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
return FillWithFallback( return FillWithFallback(
cursor, cursor,
Min(slot.TargetEndUtc, limit), Min(slot.TargetEndUtc, limit),
run, run,
slot.SlotId, slot.SlotId,
PlannedItemKind.SignOff, PlannedItemKind.SignOff,
trace trace
); );
case SlotKind.Repeat: case SlotKind.Repeat:
return FillRepeat(slot, cursor, limit, trace, run); return FillRepeat(slot, cursor, limit, trace, run);
default: default:
return FillContent(slot, cursor, limit, trace, run); return FillContent(slot, cursor, limit, trace, run);
} }
} }
private static DateTimeOffset FillRepeat( private static DateTimeOffset FillRepeat(
PlanningSlot slot, PlanningSlot slot,
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset limit, DateTimeOffset limit,
PlanTrace trace, PlanTrace trace,
PlanningRun run PlanningRun run
) )
{ {
var units = slot.RepeatUnits ?? []; var units = slot.RepeatUnits ?? [];
if (units.Count == 0) if (units.Count == 0)
{ {
run.Warnings.Add( run.Warnings.Add(
new PlanningWarning( new PlanningWarning(
PlanningWarningKind.RepeatSourceEmpty, PlanningWarningKind.RepeatSourceEmpty,
slot.SlotId, slot.SlotId,
"В источнике повтора ничего не нашлось — слот закрыт фоном." "В источнике повтора ничего не нашлось — слот закрыт фоном."
) )
); );
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId); return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
} }
var slotEnd = Min(slot.TargetEndUtc, limit); var slotEnd = Min(slot.TargetEndUtc, limit);
foreach (var unit in units) foreach (var unit in units)
{ {
if (cursor + unit.Duration > slotEnd) if (cursor + unit.Duration > slotEnd)
break; break;
run.Items.Add(Program(unit, cursor, slot.SlotId, trace)); run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
cursor += unit.Duration; cursor += unit.Duration;
} }
return cursor; return cursor;
} }
private static DateTimeOffset FillContent( private static DateTimeOffset FillContent(
PlanningSlot slot, PlanningSlot slot,
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset limit, DateTimeOffset limit,
PlanTrace trace, PlanTrace trace,
PlanningRun run PlanningRun run
) )
{ {
var pick = ElementSelector.Select(slot, cursor, run.Random); var pick = ElementSelector.Select(slot, cursor, run.Random);
if (pick is null) if (pick is null)
{ {
run.Warnings.Add(NoCandidatesWarning(slot)); run.Warnings.Add(NoCandidatesWarning(slot));
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId); return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
} }
if (pick.CooldownExhausted) if (pick.CooldownExhausted)
run.Warnings.Add( run.Warnings.Add(
new PlanningWarning( new PlanningWarning(
PlanningWarningKind.CooldownExhausted, PlanningWarningKind.CooldownExhausted,
slot.SlotId, slot.SlotId,
"Остывание отсекло всех кандидатов — взят самый давний." "Остывание отсекло всех кандидатов — взят самый давний."
) )
); );
var element = pick.Element; var element = pick.Element;
var slotTrace = trace with var slotTrace = trace with
{ {
ElementKind = element.Kind, ElementKind = element.Kind,
ElementId = element.ElementId, ElementId = element.ElementId,
Strategy = slot.Strategy.Kind, Strategy = slot.Strategy.Kind,
CandidatesAfterCooldown = pick.CandidatesAfterCooldown, CandidatesAfterCooldown = pick.CandidatesAfterCooldown,
}; };
var unitIndex = pick.StartUnitIndex; var unitIndex = pick.StartUnitIndex;
var budgetEnd = Min(slot.TargetEndUtc, limit); var budgetEnd = Min(slot.TargetEndUtc, limit);
var placed = 0; var placed = 0;
var accumulated = TimeSpan.Zero; var accumulated = TimeSpan.Zero;
// SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают. // SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают.
if ( if (
slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd) && !FitsEntirely(element, unitIndex, cursor, budgetEnd)
) )
{ {
run.Cursors.Add(CursorUpdate(slot, element, unitIndex)); run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId); return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
} }
while (unitIndex < element.Units.Count && cursor < limit) while (unitIndex < element.Units.Count && cursor < limit)
{ {
var unit = element.Units[unitIndex]; var unit = element.Units[unitIndex];
// Врезки между единицами: перед каждой, кроме первой в блоке. // Врезки между единицами: перед каждой, кроме первой в блоке.
if (placed > 0) if (placed > 0)
cursor = JunctionFiller.Fill( cursor = JunctionFiller.Fill(
slot.JunctionBetween, slot.JunctionBetween,
cursor, cursor,
limit, limit,
new JunctionPlacement( new JunctionPlacement(
slot.SlotId, slot.SlotId,
run.PreviousShowId, run.PreviousShowId,
unit.ShowId, unit.ShowId,
ElementChanged: run.PreviousShowId != unit.ShowId ElementChanged: run.PreviousShowId != unit.ShowId
), ),
run.Junctions, run.Junctions,
run.Items, run.Items,
slotTrace slotTrace
); );
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе. // Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
if (cursor + unit.Duration > limit) if (cursor + unit.Duration > limit)
break; break;
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd)) if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break; break;
run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element))); run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
cursor += unit.Duration; cursor += unit.Duration;
accumulated += unit.Duration; accumulated += unit.Duration;
unitIndex++; unitIndex++;
placed++; placed++;
run.PreviousShowId = unit.ShowId; run.PreviousShowId = unit.ShowId;
} }
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя. // Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
if (placed > 0) if (placed > 0)
cursor = JunctionFiller.Fill( cursor = JunctionFiller.Fill(
slot.JunctionAfter, slot.JunctionAfter,
cursor, cursor,
limit, limit,
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true), new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
run.Junctions, run.Junctions,
run.Items, run.Items,
slotTrace slotTrace
); );
run.Cursors.Add(CursorUpdate(slot, element, unitIndex)); run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени. // Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd) if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId); cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
return cursor; return cursor;
} }
/// <summary> /// <summary>
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором /// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав /// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
/// группы. /// группы.
/// </summary> /// </summary>
private static PlanningWarning NoCandidatesWarning(PlanningSlot slot) private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
{ {
var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0); var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
var hasAllowed = slot.Elements.Any(e => var hasAllowed = slot.Elements.Any(e =>
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e) e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
); );
return hasPlayable && !hasAllowed return hasPlayable && !hasAllowed
? new PlanningWarning( ? new PlanningWarning(
PlanningWarningKind.CandidatesFiltered, PlanningWarningKind.CandidatesFiltered,
slot.SlotId, slot.SlotId,
"Возрастной потолок отсёк всех кандидатов — место закрыл фон." "Возрастной потолок отсёк всех кандидатов — место закрыл фон."
) )
: new PlanningWarning( : new PlanningWarning(
PlanningWarningKind.SlotEmpty, PlanningWarningKind.SlotEmpty,
slot.SlotId, slot.SlotId,
"Слот не дал контента — место закрыл фон." "Слот не дал контента — место закрыл фон."
); );
} }
/// <summary> /// <summary>
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет /// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь. /// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
/// </summary> /// </summary>
private static bool WithinBudget( private static bool WithinBudget(
PlanningSlot slot, PlanningSlot slot,
int placed, int placed,
TimeSpan accumulated, TimeSpan accumulated,
DateTimeOffset cursor, DateTimeOffset cursor,
PlanningUnit unit, PlanningUnit unit,
DateTimeOffset budgetEnd DateTimeOffset budgetEnd
) )
{ {
if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot) if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot)
return true; return true;
return slot.BlockMode switch return slot.BlockMode switch
{ {
SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue), SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue),
// Последняя единица входит целиком: обрезать видеофайл нельзя. // Последняя единица входит целиком: обрезать видеофайл нельзя.
SlotBlockMode.Duration => accumulated SlotBlockMode.Duration => accumulated
< TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)), < TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)),
_ => cursor + unit.Duration <= budgetEnd, _ => cursor + unit.Duration <= budgetEnd,
}; };
} }
private static bool FitsEntirely( private static bool FitsEntirely(
PlanningElement element, PlanningElement element,
int fromUnitIndex, int fromUnitIndex,
DateTimeOffset cursor, DateTimeOffset cursor,
DateTimeOffset budgetEnd DateTimeOffset budgetEnd
) )
{ {
var total = element var total = element
.Units.Skip(fromUnitIndex) .Units.Skip(fromUnitIndex)
.Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration); .Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration);
return cursor + total <= budgetEnd; return cursor + total <= budgetEnd;
} }
/// <summary> /// <summary>
/// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком: /// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы /// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
/// остаётся незакрытым — раздача покажет там аварийный филлер канала. /// остаётся незакрытым — раздача покажет там аварийный филлер канала.
/// </summary> /// </summary>
private static DateTimeOffset FillWithFallback( private static DateTimeOffset FillWithFallback(
DateTimeOffset from, DateTimeOffset from,
DateTimeOffset until, DateTimeOffset until,
PlanningRun run, PlanningRun run,
Guid? slotId, Guid? slotId,
PlannedItemKind kind = PlannedItemKind.Fallback, PlannedItemKind kind = PlannedItemKind.Fallback,
PlanTrace? trace = null PlanTrace? trace = null
) )
{ {
var fallback = run.Input.FallbackUnits; var fallback = run.Input.FallbackUnits;
if (fallback.Count == 0 || until <= from) if (fallback.Count == 0 || until <= from)
return from; return from;
var cursor = from; var cursor = from;
var index = 0; var index = 0;
var guard = 0; var guard = 0;
while (cursor < until && guard++ < IterationBackstop) while (cursor < until && guard++ < IterationBackstop)
{ {
var unit = fallback[index % fallback.Count]; var unit = fallback[index % fallback.Count];
index++; index++;
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until) if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
break; break;
run.Items.Add( run.Items.Add(
new PlannedItem( new PlannedItem(
unit.MediaAssetId, unit.MediaAssetId,
cursor, cursor,
cursor + unit.Duration, cursor + unit.Duration,
null, null,
null, null,
slotId, slotId,
kind, kind,
trace trace
) )
); );
cursor += unit.Duration; cursor += unit.Duration;
} }
return cursor; return cursor;
} }
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary> /// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
private static Guid? CollectionOf(PlanningElement element) => private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null; element.Kind == GroupElementKind.Collection ? element.ElementId : null;
private static PlannedItem Program( private static PlannedItem Program(
PlanningUnit unit, PlanningUnit unit,
DateTimeOffset start, DateTimeOffset start,
Guid slotId, Guid slotId,
PlanTrace trace, PlanTrace trace,
Guid? collectionId = null Guid? collectionId = null
) => ) =>
new( new(
unit.MediaAssetId, unit.MediaAssetId,
start, start,
start + unit.Duration, start + unit.Duration,
unit.ShowId, unit.ShowId,
unit.UnitIndex, unit.UnitIndex,
slotId, slotId,
PlannedItemKind.Program, PlannedItemKind.Program,
trace, trace,
CollectionId: collectionId CollectionId: collectionId
); );
private static PlanningCursorUpdate CursorUpdate( private static PlanningCursorUpdate CursorUpdate(
PlanningSlot slot, PlanningSlot slot,
PlanningElement element, PlanningElement element,
int nextUnitIndex int nextUnitIndex
) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex); ) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex);
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary> /// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex) private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
{ {
for (var i = fromIndex; i < slots.Count; i++) for (var i = fromIndex; i < slots.Count; i++)
if (slots[i].IsAnchor) if (slots[i].IsAnchor)
return slots[i].TargetStartUtc; return slots[i].TargetStartUtc;
return null; return null;
} }
private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) => private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) =>
other is { } o && o < value ? o : value; other is { } o && o < value ? o : value;
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b; private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b;
/// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary> /// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary>
private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step) private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step)
{ {
if (step <= TimeSpan.Zero) if (step <= TimeSpan.Zero)
return moment; return moment;
var ticks = step.Ticks; var ticks = step.Ticks;
var remainder = moment.UtcTicks % ticks; var remainder = moment.UtcTicks % ticks;
return remainder == 0 ? moment : moment.AddTicks(ticks - remainder); return remainder == 0 ? moment : moment.AddTicks(ticks - remainder);
} }
} }
@@ -1,110 +1,110 @@
namespace TeleWave.Domain.Programming; namespace TeleWave.Domain.Programming;
/// <summary> /// <summary>
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал — /// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма /// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
/// для одного и того же. На другой канал переносится глубокой копией. /// для одного и того же. На другой канал переносится глубокой копией.
/// ///
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает /// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
/// шаблон изменённым, а хвост пересобирается отдельной командой применения. /// шаблон изменённым, а хвост пересобирается отдельной командой применения.
/// </summary> /// </summary>
public class ScheduleTemplate public class ScheduleTemplate
{ {
private readonly List<GridLayer> _layers = []; private readonly List<GridLayer> _layers = [];
public Guid Id { get; private set; } public Guid Id { get; private set; }
public Guid ChannelId { get; private set; } public Guid ChannelId { get; private set; }
public string Name { get; private set; } = string.Empty; public string Name { get; private set; } = string.Empty;
/// <summary>Аварийная группа, если пуст даже фоновый слой.</summary> /// <summary>Аварийная группа, если пуст даже фоновый слой.</summary>
public Guid? FallbackGroupId { get; private set; } public Guid? FallbackGroupId { get; private set; }
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary> /// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
public Guid? DefaultJunctionId { get; private set; } public Guid? DefaultJunctionId { get; private set; }
/// <summary> /// <summary>
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает — /// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
/// схема живёт в Application, как и у стратегий слотов и условий стыка. /// схема живёт в Application, как и у стратегий слотов и условий стыка.
/// </summary> /// </summary>
public string? RulesJson { get; private set; } public string? RulesJson { get; private set; }
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary> /// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
public int Revision { get; private set; } public int Revision { get; private set; }
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary> /// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
public int AppliedRevision { get; private set; } public int AppliedRevision { get; private set; }
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyList<GridLayer> Layers => _layers; public IReadOnlyList<GridLayer> Layers => _layers;
/// <summary>Есть ли правки, не применённые к эфиру.</summary> /// <summary>Есть ли правки, не применённые к эфиру.</summary>
public bool HasPendingChanges => Revision != AppliedRevision; public bool HasPendingChanges => Revision != AppliedRevision;
private const string BackgroundLayerName = "Фон"; private const string BackgroundLayerName = "Фон";
private ScheduleTemplate() { } private ScheduleTemplate() { }
public static ScheduleTemplate Create(Guid channelId, string name) public static ScheduleTemplate Create(Guid channelId, string name)
{ {
var template = new ScheduleTemplate var template = new ScheduleTemplate
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ChannelId = channelId, ChannelId = channelId,
Name = name.Trim(), Name = name.Trim(),
Revision = 0, Revision = 0,
AppliedRevision = 0, AppliedRevision = 0,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
// Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть. // Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть.
template._layers.Add( template._layers.Add(
GridLayer.Create( GridLayer.Create(
template.Id, template.Id,
BackgroundLayerName, BackgroundLayerName,
GridLayer.BackgroundPriority, GridLayer.BackgroundPriority,
isBackground: true isBackground: true
) )
); );
return template; return template;
} }
public void Rename(string name) => Name = name.Trim(); public void Rename(string name) => Name = name.Trim();
public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId; public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId;
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId; public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
public void SetRules(string? rulesJson) => public void SetRules(string? rulesJson) =>
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson; RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary> /// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
public void MarkChanged() => Revision++; public void MarkChanged() => Revision++;
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary> /// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
public void MarkApplied() => AppliedRevision = Revision; public void MarkApplied() => AppliedRevision = Revision;
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId); public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground); public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground);
public GridLayer AddLayer(string name, int priority) public GridLayer AddLayer(string name, int priority)
{ {
var layer = GridLayer.Create(Id, name, priority); var layer = GridLayer.Create(Id, name, priority);
_layers.Add(layer); _layers.Add(layer);
return layer; return layer;
} }
/// <summary>Удаляет слой. Фоновый удалить нельзя — вернёт false.</summary> /// <summary>Удаляет слой. Фоновый удалить нельзя — вернёт false.</summary>
public bool RemoveLayer(Guid layerId) public bool RemoveLayer(Guid layerId)
{ {
var layer = _layers.FirstOrDefault(l => l.Id == layerId); var layer = _layers.FirstOrDefault(l => l.Id == layerId);
if (layer is null || layer.IsBackground) if (layer is null || layer.IsBackground)
return false; return false;
_layers.Remove(layer); _layers.Remove(layer);
return true; return true;
} }
/// <summary>Слой, содержащий слот, или null.</summary> /// <summary>Слой, содержащий слот, или null.</summary>
public GridLayer? FindLayerOfSlot(Guid slotId) => public GridLayer? FindLayerOfSlot(Guid slotId) =>
_layers.FirstOrDefault(l => l.FindSlot(slotId) is not null); _layers.FirstOrDefault(l => l.FindSlot(slotId) is not null);
} }
+131 -131
View File
@@ -1,131 +1,131 @@
namespace TeleWave.Domain.Programming; namespace TeleWave.Domain.Programming;
/// <summary> /// <summary>
/// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык, /// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык,
/// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат /// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат
/// якоря (<see cref="IsAnchor"/>) и мягкое округление (<see cref="SnapToMinutes"/>). /// якоря (<see cref="IsAnchor"/>) и мягкое округление (<see cref="SnapToMinutes"/>).
/// </summary> /// </summary>
public class Slot public class Slot
{ {
public Guid Id { get; private set; } public Guid Id { get; private set; }
public Guid LayerId { get; private set; } public Guid LayerId { get; private set; }
/// <summary>День недели вещательных суток (0=Вс..6=Сб) или null — каждый день.</summary> /// <summary>День недели вещательных суток (0=Вс..6=Сб) или null — каждый день.</summary>
public int? Weekday { get; private set; } public int? Weekday { get; private set; }
/// <summary>Целевое время старта в сутках канала.</summary> /// <summary>Целевое время старта в сутках канала.</summary>
public TimeOnly TargetStart { get; private set; } public TimeOnly TargetStart { get; private set; }
/// <summary>Бюджет слота в минутах.</summary> /// <summary>Бюджет слота в минутах.</summary>
public int TargetDurationMinutes { get; private set; } public int TargetDurationMinutes { get; private set; }
public string Title { get; private set; } = string.Empty; public string Title { get; private set; } = string.Empty;
public Daypart Daypart { get; private set; } public Daypart Daypart { get; private set; }
public SlotKind SlotKind { get; private set; } public SlotKind SlotKind { get; private set; }
/// <summary>Группа контента — для <see cref="SlotKind.Content"/>.</summary> /// <summary>Группа контента — для <see cref="SlotKind.Content"/>.</summary>
public Guid? GroupId { get; private set; } public Guid? GroupId { get; private set; }
/// <summary>Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application.</summary> /// <summary>Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application.</summary>
public string? StrategyJson { get; private set; } public string? StrategyJson { get; private set; }
/// <summary>Откуда брать повтор (JSON) — для <see cref="SlotKind.Repeat"/>.</summary> /// <summary>Откуда брать повтор (JSON) — для <see cref="SlotKind.Repeat"/>.</summary>
public string? RepeatSourceJson { get; private set; } public string? RepeatSourceJson { get; private set; }
public SlotBlockMode BlockMode { get; private set; } public SlotBlockMode BlockMode { get; private set; }
/// <summary>Единиц (<see cref="SlotBlockMode.Count"/>) или минут (<see cref="SlotBlockMode.Duration"/>).</summary> /// <summary>Единиц (<see cref="SlotBlockMode.Count"/>) или минут (<see cref="SlotBlockMode.Duration"/>).</summary>
public int BlockValue { get; private set; } public int BlockValue { get; private set; }
public OverflowPolicy OverflowPolicy { get; private set; } public OverflowPolicy OverflowPolicy { get; private set; }
/// <summary>Старт жёсткий: генератор не начнёт единицу, которая через него перелезет.</summary> /// <summary>Старт жёсткий: генератор не начнёт единицу, которая через него перелезет.</summary>
public bool IsAnchor { get; private set; } public bool IsAnchor { get; private set; }
/// <summary>Допуск отклонения фактического старта от целевого, минуты.</summary> /// <summary>Допуск отклонения фактического старта от целевого, минуты.</summary>
public int MaxDriftMinutes { get; private set; } public int MaxDriftMinutes { get; private set; }
/// <summary> /// <summary>
/// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если /// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если
/// добирать пришлось бы дольше <see cref="MaxDriftMinutes"/>, округление пропускается. /// добирать пришлось бы дольше <see cref="MaxDriftMinutes"/>, округление пропускается.
/// </summary> /// </summary>
public int? SnapToMinutes { get; private set; } public int? SnapToMinutes { get; private set; }
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary> /// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
public Guid? JunctionBetweenId { get; private set; } public Guid? JunctionBetweenId { get; private set; }
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary> /// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
public Guid? JunctionAfterId { get; private set; } public Guid? JunctionAfterId { get; private set; }
public const int DefaultMaxDriftMinutes = 5; public const int DefaultMaxDriftMinutes = 5;
private Slot() { } private Slot() { }
public static Slot Create( public static Slot Create(
Guid layerId, Guid layerId,
string title, string title,
TimeOnly targetStart, TimeOnly targetStart,
int targetDurationMinutes, int targetDurationMinutes,
Daypart daypart = Daypart.Day, Daypart daypart = Daypart.Day,
SlotKind slotKind = SlotKind.Content, SlotKind slotKind = SlotKind.Content,
int? weekday = null int? weekday = null
) => ) =>
new() new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
LayerId = layerId, LayerId = layerId,
Title = title.Trim(), Title = title.Trim(),
TargetStart = targetStart, TargetStart = targetStart,
TargetDurationMinutes = Math.Max(1, targetDurationMinutes), TargetDurationMinutes = Math.Max(1, targetDurationMinutes),
Daypart = daypart, Daypart = daypart,
SlotKind = slotKind, SlotKind = slotKind,
Weekday = weekday, Weekday = weekday,
BlockMode = SlotBlockMode.FillSlot, BlockMode = SlotBlockMode.FillSlot,
BlockValue = 1, BlockValue = 1,
OverflowPolicy = OverflowPolicy.ContinueNext, OverflowPolicy = OverflowPolicy.ContinueNext,
IsAnchor = false, IsAnchor = false,
MaxDriftMinutes = DefaultMaxDriftMinutes, MaxDriftMinutes = DefaultMaxDriftMinutes,
}; };
/// <summary>Правит расписание слота: когда, сколько и как выравнивать.</summary> /// <summary>Правит расписание слота: когда, сколько и как выравнивать.</summary>
public void UpdateTiming( public void UpdateTiming(
int? weekday, int? weekday,
TimeOnly targetStart, TimeOnly targetStart,
int targetDurationMinutes, int targetDurationMinutes,
Daypart daypart, Daypart daypart,
bool isAnchor, bool isAnchor,
int maxDriftMinutes, int maxDriftMinutes,
int? snapToMinutes int? snapToMinutes
) )
{ {
Weekday = weekday is >= 0 and <= 6 ? weekday : null; Weekday = weekday is >= 0 and <= 6 ? weekday : null;
TargetStart = targetStart; TargetStart = targetStart;
TargetDurationMinutes = Math.Max(1, targetDurationMinutes); TargetDurationMinutes = Math.Max(1, targetDurationMinutes);
Daypart = daypart; Daypart = daypart;
IsAnchor = isAnchor; IsAnchor = isAnchor;
MaxDriftMinutes = Math.Max(0, maxDriftMinutes); MaxDriftMinutes = Math.Max(0, maxDriftMinutes);
SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null; SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null;
} }
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary> /// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
public void UpdateContent(SlotContent content) public void UpdateContent(SlotContent content)
{ {
JunctionBetweenId = content.JunctionBetweenId; JunctionBetweenId = content.JunctionBetweenId;
JunctionAfterId = content.JunctionAfterId; JunctionAfterId = content.JunctionAfterId;
Title = content.Title.Trim(); Title = content.Title.Trim();
SlotKind = content.SlotKind; SlotKind = content.SlotKind;
BlockMode = content.BlockMode; BlockMode = content.BlockMode;
BlockValue = Math.Max(1, content.BlockValue); BlockValue = Math.Max(1, content.BlockValue);
OverflowPolicy = content.OverflowPolicy; OverflowPolicy = content.OverflowPolicy;
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют, // Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка. // и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null; GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null; StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null; RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
} }
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary> /// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
public TimeSpan TargetEndOffset => public TimeSpan TargetEndOffset =>
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes); TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
} }
@@ -1,96 +1,96 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Planning;
namespace TeleWave.Infrastructure.Broadcast; namespace TeleWave.Infrastructure.Broadcast;
/// <summary> /// <summary>
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста — /// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
/// без удаления существующих записей, поэтому эфир не «дёргается». /// без удаления существующих записей, поэтому эфир не «дёргается».
/// </summary> /// </summary>
public sealed class SchedulingBackgroundService( public sealed class SchedulingBackgroundService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptions<SchedulerOptions> options, IOptions<SchedulerOptions> options,
ILogger<SchedulingBackgroundService> logger ILogger<SchedulingBackgroundService> logger
) : BackgroundService ) : BackgroundService
{ {
private readonly SchedulerOptions _options = options.Value; private readonly SchedulerOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
// Небольшая задержка на старте — дать примениться миграциям/сидингу. // Небольшая задержка на старте — дать примениться миграциям/сидингу.
try try
{ {
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
return; return;
} }
using var timer = new PeriodicTimer( using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes)) TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
); );
do do
{ {
try try
{ {
await TickAsync(stoppingToken); await TickAsync(stoppingToken);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
break; break;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Ошибка тика планировщика"); logger.LogError(ex, "Ошибка тика планировщика");
} }
} while (await timer.WaitForNextTickAsync(stoppingToken)); } while (await timer.WaitForNextTickAsync(stoppingToken));
} }
private async Task TickAsync(CancellationToken cancellationToken) private async Task TickAsync(CancellationToken cancellationToken)
{ {
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var generator = scope.ServiceProvider.GetRequiredService<GridScheduleGenerator>(); var generator = scope.ServiceProvider.GetRequiredService<GridScheduleGenerator>();
// Эфир строит только шаблон сетки: канал без шаблона вещать не может и молча пропускается. // Эфир строит только шаблон сетки: канал без шаблона вещать не может и молча пропускается.
var channelIds = await db var channelIds = await db
.Channels.Where(c => c.IsEnabled && c.TemplateId != null) .Channels.Where(c => c.IsEnabled && c.TemplateId != null)
.Select(c => c.Id) .Select(c => c.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
foreach (var channelId in channelIds) foreach (var channelId in channelIds)
{ {
var report = await generator.GenerateAsync( var report = await generator.GenerateAsync(
channelId, channelId,
now, now,
rebuildFuture: false, rebuildFuture: false,
cancellationToken cancellationToken
); );
if (report.Added > 0) if (report.Added > 0)
logger.LogInformation( logger.LogInformation(
"Канал {ChannelId}: добавлено {Count} записей расписания", "Канал {ChannelId}: добавлено {Count} записей расписания",
channelId, channelId,
report.Added report.Added
); );
foreach (var warning in report.Warnings) foreach (var warning in report.Warnings)
logger.LogWarning( logger.LogWarning(
"Канал {ChannelId}, слот {SlotId}: {Warning} — {Details}", "Канал {ChannelId}, слот {SlotId}: {Warning} — {Details}",
channelId, channelId,
warning.SlotId, warning.SlotId,
warning.Kind, warning.Kind,
warning.Details warning.Details
); );
} }
} }
} }
@@ -1,168 +1,168 @@
using System.Text; using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Infrastructure.Broadcast; using TeleWave.Infrastructure.Broadcast;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Library; using TeleWave.Infrastructure.Library;
using TeleWave.Infrastructure.Media; using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Metadata; using TeleWave.Infrastructure.Metadata;
using TeleWave.Infrastructure.Persistence; using TeleWave.Infrastructure.Persistence;
using TeleWave.Infrastructure.Settings; using TeleWave.Infrastructure.Settings;
using TeleWave.Infrastructure.Streaming; using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Infrastructure; namespace TeleWave.Infrastructure;
/// <summary> /// <summary>
/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация. /// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация.
/// </summary> /// </summary>
public static class DependencyInjection public static class DependencyInjection
{ {
public static IServiceCollection AddInfrastructure( public static IServiceCollection AddInfrastructure(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration IConfiguration configuration
) )
{ {
services.AddDbContext<AppDbContext>(options => services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql( options.UseNpgsql(
configuration["ConnectionStrings:Default"] configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Строка подключения 'ConnectionStrings:Default' не сконфигурирована." "Строка подключения 'ConnectionStrings:Default' не сконфигурирована."
) )
) )
); );
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>()); services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services services
.AddIdentityCore<AppUser>(options => .AddIdentityCore<AppUser>(options =>
{ {
options.User.RequireUniqueEmail = false; options.User.RequireUniqueEmail = false;
options.Password.RequiredLength = 8; options.Password.RequiredLength = 8;
options.Password.RequireDigit = true; options.Password.RequireDigit = true;
options.Password.RequireUppercase = true; options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false; options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.AllowedForNewUsers = true; options.Lockout.AllowedForNewUsers = true;
}) })
.AddRoles<AppRole>() .AddRoles<AppRole>()
.AddEntityFrameworkStores<AppDbContext>() .AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager() .AddSignInManager()
.AddDefaultTokenProviders(); .AddDefaultTokenProviders();
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName)); services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<AdminSeedOptions>( services.Configure<AdminSeedOptions>(
configuration.GetSection(AdminSeedOptions.SectionName) configuration.GetSection(AdminSeedOptions.SectionName)
); );
var jwtOptions = var jwtOptions =
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана."); ?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана.");
// Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены // Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены
// (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход // (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход
// авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым. // авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым.
// HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом. // HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом.
if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32) if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32)
throw new InvalidOperationException( throw new InvalidOperationException(
"Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey." "Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey."
); );
if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase)) if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException( throw new InvalidOperationException(
"Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)." "Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)."
); );
services services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer, ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true, ValidateAudience = true,
ValidAudience = jwtOptions.Audience, ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey( IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey) Encoding.UTF8.GetBytes(jwtOptions.SigningKey)
), ),
ValidateLifetime = true, ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30), ClockSkew = TimeSpan.FromSeconds(30),
}; };
}); });
services.AddAuthorization(); services.AddAuthorization();
services.AddScoped<IIdentityService, IdentityService>(); services.AddScoped<IIdentityService, IdentityService>();
services.AddScoped<IJwtTokenService, JwtTokenService>(); services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>(); services.AddScoped<IRefreshTokenService, RefreshTokenService>();
services.AddScoped<IRoleService, RoleService>(); services.AddScoped<IRoleService, RoleService>();
services.AddScoped<ICurrentUser, CurrentUser>(); services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<ISiteSettings, SiteSettings>(); services.AddScoped<ISiteSettings, SiteSettings>();
services.AddScoped<DbInitializer>(); services.AddScoped<DbInitializer>();
services.AddScoped<GenreSeeder>(); services.AddScoped<GenreSeeder>();
AddMedia(services, configuration); AddMedia(services, configuration);
AddBroadcast(services, configuration); AddBroadcast(services, configuration);
AddMetadata(services, configuration); AddMetadata(services, configuration);
return services; return services;
} }
/// <summary>Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок.</summary> /// <summary>Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок.</summary>
private static void AddMetadata(IServiceCollection services, IConfiguration configuration) private static void AddMetadata(IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<MetadataOptions>(configuration.GetSection(MetadataOptions.SectionName)); services.Configure<MetadataOptions>(configuration.GetSection(MetadataOptions.SectionName));
services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15)); services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15));
services.AddSingleton<IMetadataProvider, TmdbMetadataProvider>(); services.AddSingleton<IMetadataProvider, TmdbMetadataProvider>();
services.AddSingleton<IMetadataProvider, OmdbMetadataProvider>(); services.AddSingleton<IMetadataProvider, OmdbMetadataProvider>();
services.AddSingleton<IMetadataProviderResolver, MetadataProviderResolver>(); services.AddSingleton<IMetadataProviderResolver, MetadataProviderResolver>();
} }
/// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary> /// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary>
private static void AddBroadcast(IServiceCollection services, IConfiguration configuration) private static void AddBroadcast(IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<SchedulerOptions>( services.Configure<SchedulerOptions>(
configuration.GetSection(SchedulerOptions.SectionName) configuration.GetSection(SchedulerOptions.SectionName)
); );
services.Configure<StreamingOptions>( services.Configure<StreamingOptions>(
configuration.GetSection(StreamingOptions.SectionName) configuration.GetSection(StreamingOptions.SectionName)
); );
services.Configure<BumperOptions>(configuration.GetSection(BumperOptions.SectionName)); services.Configure<BumperOptions>(configuration.GetSection(BumperOptions.SectionName));
services.AddSingleton<IRandomSource, SystemRandomSource>(); services.AddSingleton<IRandomSource, SystemRandomSource>();
services.AddSingleton<StreamTokenService>(); services.AddSingleton<StreamTokenService>();
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>(); services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
services.AddHostedService<SchedulingBackgroundService>(); services.AddHostedService<SchedulingBackgroundService>();
services.AddHostedService<MaintenanceBackgroundService>(); services.AddHostedService<MaintenanceBackgroundService>();
} }
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary> /// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
private static void AddMedia(IServiceCollection services, IConfiguration configuration) private static void AddMedia(IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName)); services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName)); services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
services.AddSingleton<MediaPathResolver>(); services.AddSingleton<MediaPathResolver>();
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>(); services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>(); services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
services.AddSingleton<IImageStore, ImageStore>(); services.AddSingleton<IImageStore, ImageStore>();
services.AddSingleton<IImageDownloader, ImageDownloader>(); services.AddSingleton<IImageDownloader, ImageDownloader>();
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>(); services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>(); services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>(); services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>(); services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
services.AddHostedService<MediaProcessingBackgroundService>(); services.AddHostedService<MediaProcessingBackgroundService>();
services.AddHostedService<InboxScannerBackgroundService>(); services.AddHostedService<InboxScannerBackgroundService>();
services.AddHostedService<BumperRenderBackgroundService>(); services.AddHostedService<BumperRenderBackgroundService>();
} }
} }
@@ -1,171 +1,171 @@
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media; using TeleWave.Application.Media;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary> /// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
{ {
private const int CopyBufferSize = 1024 * 1024; private const int CopyBufferSize = 1024 * 1024;
public long GetAvailableFreeSpaceBytes() public long GetAvailableFreeSpaceBytes()
{ {
try try
{ {
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace; return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
} }
catch (Exception ex) when (ex is ArgumentException or IOException) catch (Exception ex) when (ex is ArgumentException or IOException)
{ {
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования). // Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
return long.MaxValue; return long.MaxValue;
} }
} }
public async Task<string> SaveUploadAsync( public async Task<string> SaveUploadAsync(
Stream content, Stream content,
string extension, string extension,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
Directory.CreateDirectory(paths.UploadsDir); Directory.CreateDirectory(paths.UploadsDir);
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant(); var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
var path = paths.UploadPath(token); var path = paths.UploadPath(token);
await using var file = new FileStream( await using var file = new FileStream(
path, path,
FileMode.CreateNew, FileMode.CreateNew,
FileAccess.Write, FileAccess.Write,
FileShare.None, FileShare.None,
CopyBufferSize, CopyBufferSize,
useAsync: true useAsync: true
); );
await content.CopyToAsync(file, CopyBufferSize, cancellationToken); await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
return token; return token;
} }
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken) public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
{ {
var path = paths.UploadPath(uploadToken); var path = paths.UploadPath(uploadToken);
if (File.Exists(path)) if (File.Exists(path))
File.Delete(path); File.Delete(path);
return Task.CompletedTask; return Task.CompletedTask;
} }
public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max) public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
{ {
if (!Directory.Exists(paths.ManualDir)) if (!Directory.Exists(paths.ManualDir))
return []; return [];
return Directory return Directory
.EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories) .EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
.Take(Math.Max(1, max)) .Take(Math.Max(1, max))
.Select(path => new FileInfo(path)) .Select(path => new FileInfo(path))
.Where(file => file.Exists) .Where(file => file.Exists)
.Select(file => new IMediaStorage.ManualInboxFile( .Select(file => new IMediaStorage.ManualInboxFile(
// Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса. // Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'), Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
file.Name, file.Name,
file.Length file.Length
)) ))
.OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase) .OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
} }
public Task CleanupManualLeftoversAsync( public Task CleanupManualLeftoversAsync(
string relativePath, string relativePath,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var path = paths.ManualPath(relativePath); var path = paths.ManualPath(relativePath);
var directory = Path.GetDirectoryName(path); var directory = Path.GetDirectoryName(path);
if (directory is null || !Directory.Exists(directory)) if (directory is null || !Directory.Exists(directory))
return Task.CompletedTask; return Task.CompletedTask;
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки: // Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки:
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно — // так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
// «Серия.Extended.mkv» это не мусор, а другой материал. // «Серия.Extended.mkv» это не мусор, а другой материал.
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается // Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным. // «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным.
var prefix = Path.GetFileNameWithoutExtension(path) + "."; var prefix = Path.GetFileNameWithoutExtension(path) + ".";
foreach (var sibling in Directory.EnumerateFiles(directory)) foreach (var sibling in Directory.EnumerateFiles(directory))
{ {
var name = Path.GetFileName(sibling); var name = Path.GetFileName(sibling);
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
continue; continue;
if (MediaFormats.IsAllowed(name)) if (MediaFormats.IsAllowed(name))
continue; continue;
try try
{ {
File.Delete(sibling); File.Delete(sibling);
} }
catch (IOException) catch (IOException)
{ {
// Файл занят или уже удалён — не повод валить импорт целиком. // Файл занят или уже удалён — не повод валить импорт целиком.
} }
} }
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда. // Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда.
if ( if (
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal) !string.Equals(directory, paths.ManualDir, StringComparison.Ordinal)
&& !Directory.EnumerateFileSystemEntries(directory).Any() && !Directory.EnumerateFileSystemEntries(directory).Any()
) )
{ {
try try
{ {
Directory.Delete(directory); Directory.Delete(directory);
} }
catch (IOException) catch (IOException)
{ {
// Каталог занят — оставим как есть. // Каталог занят — оставим как есть.
} }
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task PromoteToOriginalAsync( public Task PromoteToOriginalAsync(
MediaSource source, MediaSource source,
string sourceToken, string sourceToken,
Guid assetId, Guid assetId,
string extension, string extension,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var sourcePath = source switch var sourcePath = source switch
{ {
MediaSource.Inbox => paths.InboxPath(sourceToken), MediaSource.Inbox => paths.InboxPath(sourceToken),
MediaSource.ManualInbox => paths.ManualPath(sourceToken), MediaSource.ManualInbox => paths.ManualPath(sourceToken),
_ => paths.UploadPath(sourceToken), _ => paths.UploadPath(sourceToken),
}; };
if (!File.Exists(sourcePath)) if (!File.Exists(sourcePath))
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath); throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
Directory.CreateDirectory(paths.OriginalsDir); Directory.CreateDirectory(paths.OriginalsDir);
var destination = paths.OriginalPath(assetId, extension); var destination = paths.OriginalPath(assetId, extension);
File.Move(sourcePath, destination, overwrite: true); File.Move(sourcePath, destination, overwrite: true);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task DeleteAssetArtifactsAsync( public Task DeleteAssetArtifactsAsync(
Guid assetId, Guid assetId,
string extension, string extension,
CancellationToken cancellationToken CancellationToken cancellationToken
) => ) =>
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока // Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O. // (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
Task.Run( Task.Run(
() => () =>
{ {
var original = paths.OriginalPath(assetId, extension); var original = paths.OriginalPath(assetId, extension);
if (File.Exists(original)) if (File.Exists(original))
File.Delete(original); File.Delete(original);
var assetDir = paths.AssetDir(assetId); var assetDir = paths.AssetDir(assetId);
if (Directory.Exists(assetDir)) if (Directory.Exists(assetDir))
Directory.Delete(assetDir, recursive: true); Directory.Delete(assetDir, recursive: true);
}, },
cancellationToken cancellationToken
); );
} }
@@ -1,111 +1,111 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary> /// <summary>
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из /// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня. /// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
/// </summary> /// </summary>
public sealed class MediaPathResolver public sealed class MediaPathResolver
{ {
private readonly string _root; private readonly string _root;
public MediaPathResolver(IOptions<StorageOptions> options) public MediaPathResolver(IOptions<StorageOptions> options)
{ {
_root = Path.GetFullPath(options.Value.RootPath); _root = Path.GetFullPath(options.Value.RootPath);
InboxDir = Path.Combine(_root, "inbox"); InboxDir = Path.Combine(_root, "inbox");
ManualDir = Path.Combine(_root, "manual"); ManualDir = Path.Combine(_root, "manual");
UploadsDir = Path.Combine(_root, "uploads"); UploadsDir = Path.Combine(_root, "uploads");
OriginalsDir = Path.Combine(_root, "originals"); OriginalsDir = Path.Combine(_root, "originals");
AssetsDir = Path.Combine(_root, "assets"); AssetsDir = Path.Combine(_root, "assets");
BumpersDir = Path.Combine(_root, "bumpers"); BumpersDir = Path.Combine(_root, "bumpers");
ImagesDir = Path.Combine(_root, "images"); ImagesDir = Path.Combine(_root, "images");
} }
public string InboxDir { get; } public string InboxDir { get; }
/// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary> /// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary>
public string ManualDir { get; } public string ManualDir { get; }
public string UploadsDir { get; } public string UploadsDir { get; }
public string OriginalsDir { get; } public string OriginalsDir { get; }
public string AssetsDir { get; } public string AssetsDir { get; }
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary> /// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
public string BumpersDir { get; } public string BumpersDir { get; }
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary> /// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
public string ImagesDir { get; } public string ImagesDir { get; }
public void EnsureDirectories() public void EnsureDirectories()
{ {
Directory.CreateDirectory(InboxDir); Directory.CreateDirectory(InboxDir);
Directory.CreateDirectory(ManualDir); Directory.CreateDirectory(ManualDir);
Directory.CreateDirectory(UploadsDir); Directory.CreateDirectory(UploadsDir);
Directory.CreateDirectory(OriginalsDir); Directory.CreateDirectory(OriginalsDir);
Directory.CreateDirectory(AssetsDir); Directory.CreateDirectory(AssetsDir);
Directory.CreateDirectory(BumpersDir); Directory.CreateDirectory(BumpersDir);
Directory.CreateDirectory(ImagesDir); Directory.CreateDirectory(ImagesDir);
} }
public string BumperTemplateDir(Guid templateId) => public string BumperTemplateDir(Guid templateId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"))); EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary> /// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) => public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension)); EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
/// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary> /// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
public string ImagePath(Guid imageId, string extension) => public string ImagePath(Guid imageId, string extension) =>
EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension)); EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension));
public string OriginalPath(Guid assetId, string extension) => public string OriginalPath(Guid assetId, string extension) =>
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension)); EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
public string AssetDir(Guid assetId) => public string AssetDir(Guid assetId) =>
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N"))); EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
public static string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}"; public static string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
/// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary> /// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary>
public string SegmentPath(Guid assetId, string fileName) public string SegmentPath(Guid assetId, string fileName)
{ {
var assetDir = AssetDir(assetId); var assetDir = AssetDir(assetId);
return EnsureWithin(assetDir, Path.Combine(assetDir, fileName)); return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
} }
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary> /// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
public string UploadPath(string token) => public string UploadPath(string token) =>
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token)); EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary> /// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
public string InboxPath(string fileName) => public string InboxPath(string fileName) =>
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName)); EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
/// <summary> /// <summary>
/// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки /// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки
/// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь /// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь
/// обязательна: строка приходит из запроса. /// обязательна: строка приходит из запроса.
/// </summary> /// </summary>
public string ManualPath(string relativePath) => public string ManualPath(string relativePath) =>
EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath)); EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath));
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate); private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
private static string EnsureWithin(string baseDir, string candidate) private static string EnsureWithin(string baseDir, string candidate)
{ {
var full = Path.GetFullPath(candidate); var full = Path.GetFullPath(candidate);
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar) var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar)
? baseDir ? baseDir
: baseDir + Path.DirectorySeparatorChar; : baseDir + Path.DirectorySeparatorChar;
if ( if (
!full.StartsWith(normalizedBase, StringComparison.Ordinal) !full.StartsWith(normalizedBase, StringComparison.Ordinal)
&& !string.Equals(full, baseDir, StringComparison.Ordinal) && !string.Equals(full, baseDir, StringComparison.Ordinal)
) )
throw new UnauthorizedAccessException( throw new UnauthorizedAccessException(
$"Путь '{candidate}' выходит за пределы каталога хранилища." $"Путь '{candidate}' выходит за пределы каталога хранилища."
); );
return full; return full;
} }
} }
@@ -1,330 +1,330 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class InitialCreate : Migration public partial class InitialCreate : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "AspNetRoles", name: "AspNetRoles",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false), IsSystem = table.Column<bool>(type: "boolean", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
), ),
NormalizedName = table.Column<string>( NormalizedName = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
), ),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true), ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_AspNetRoles", x => x.Id); table.PrimaryKey("PK_AspNetRoles", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "AspNetUsers", name: "AspNetUsers",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
IsBlocked = table.Column<bool>(type: "boolean", nullable: false), IsBlocked = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
UserName = table.Column<string>( UserName = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
), ),
NormalizedUserName = table.Column<string>( NormalizedUserName = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
), ),
Email = table.Column<string>( Email = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
), ),
NormalizedEmail = table.Column<string>( NormalizedEmail = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
), ),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false), EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true), PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true), SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true), ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true), PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false), PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false), TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>( LockoutEnd = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: true nullable: true
), ),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false), LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false), AccessFailedCount = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_AspNetUsers", x => x.Id); table.PrimaryKey("PK_AspNetUsers", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "RefreshTokens", name: "RefreshTokens",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false), UserId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "text", nullable: false), TokenHash = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>( ExpiresAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
RevokedAt = table.Column<DateTimeOffset>( RevokedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: true nullable: true
), ),
ReplacedByTokenHash = table.Column<string>(type: "text", nullable: true), ReplacedByTokenHash = table.Column<string>(type: "text", nullable: true),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_RefreshTokens", x => x.Id); table.PrimaryKey("PK_RefreshTokens", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "AspNetRoleClaims", name: "AspNetRoleClaims",
columns: table => new columns: table => new
{ {
Id = table Id = table
.Column<int>(type: "integer", nullable: false) .Column<int>(type: "integer", nullable: false)
.Annotation( .Annotation(
"Npgsql:ValueGenerationStrategy", "Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
), ),
RoleId = table.Column<Guid>(type: "uuid", nullable: false), RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true), ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true), ClaimValue = table.Column<string>(type: "text", nullable: true),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId, column: x => x.RoleId,
principalTable: "AspNetRoles", principalTable: "AspNetRoles",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "AspNetUserClaims", name: "AspNetUserClaims",
columns: table => new columns: table => new
{ {
Id = table Id = table
.Column<int>(type: "integer", nullable: false) .Column<int>(type: "integer", nullable: false)
.Annotation( .Annotation(
"Npgsql:ValueGenerationStrategy", "Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
), ),
UserId = table.Column<Guid>(type: "uuid", nullable: false), UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true), ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true), ClaimValue = table.Column<string>(type: "text", nullable: true),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId", name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId, column: x => x.UserId,
principalTable: "AspNetUsers", principalTable: "AspNetUsers",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "AspNetUserLogins", name: "AspNetUserLogins",
columns: table => new columns: table => new
{ {
LoginProvider = table.Column<string>(type: "text", nullable: false), LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false), ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true), ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false), UserId = table.Column<Guid>(type: "uuid", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey( table.PrimaryKey(
"PK_AspNetUserLogins", "PK_AspNetUserLogins",
x => new { x.LoginProvider, x.ProviderKey } x => new { x.LoginProvider, x.ProviderKey }
); );
table.ForeignKey( table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId", name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId, column: x => x.UserId,
principalTable: "AspNetUsers", principalTable: "AspNetUsers",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "AspNetUserRoles", name: "AspNetUserRoles",
columns: table => new columns: table => new
{ {
UserId = table.Column<Guid>(type: "uuid", nullable: false), UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false), RoleId = table.Column<Guid>(type: "uuid", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey( table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId", name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId, column: x => x.RoleId,
principalTable: "AspNetRoles", principalTable: "AspNetRoles",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
table.ForeignKey( table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId", name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId, column: x => x.UserId,
principalTable: "AspNetUsers", principalTable: "AspNetUsers",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "AspNetUserTokens", name: "AspNetUserTokens",
columns: table => new columns: table => new
{ {
UserId = table.Column<Guid>(type: "uuid", nullable: false), UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false), LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false), Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true), Value = table.Column<string>(type: "text", nullable: true),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey( table.PrimaryKey(
"PK_AspNetUserTokens", "PK_AspNetUserTokens",
x => new x => new
{ {
x.UserId, x.UserId,
x.LoginProvider, x.LoginProvider,
x.Name, x.Name,
} }
); );
table.ForeignKey( table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId", name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId, column: x => x.UserId,
principalTable: "AspNetUsers", principalTable: "AspNetUsers",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId", name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims", table: "AspNetRoleClaims",
column: "RoleId" column: "RoleId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "RoleNameIndex", name: "RoleNameIndex",
table: "AspNetRoles", table: "AspNetRoles",
column: "NormalizedName", column: "NormalizedName",
unique: true unique: true
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId", name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims", table: "AspNetUserClaims",
column: "UserId" column: "UserId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId", name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins", table: "AspNetUserLogins",
column: "UserId" column: "UserId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId", name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles", table: "AspNetUserRoles",
column: "RoleId" column: "RoleId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "EmailIndex", name: "EmailIndex",
table: "AspNetUsers", table: "AspNetUsers",
column: "NormalizedEmail" column: "NormalizedEmail"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "UserNameIndex", name: "UserNameIndex",
table: "AspNetUsers", table: "AspNetUsers",
column: "NormalizedUserName", column: "NormalizedUserName",
unique: true unique: true
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_TokenHash", name: "IX_RefreshTokens_TokenHash",
table: "RefreshTokens", table: "RefreshTokens",
column: "TokenHash", column: "TokenHash",
unique: true unique: true
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId", name: "IX_RefreshTokens_UserId",
table: "RefreshTokens", table: "RefreshTokens",
column: "UserId" column: "UserId"
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "AspNetRoleClaims"); migrationBuilder.DropTable(name: "AspNetRoleClaims");
migrationBuilder.DropTable(name: "AspNetUserClaims"); migrationBuilder.DropTable(name: "AspNetUserClaims");
migrationBuilder.DropTable(name: "AspNetUserLogins"); migrationBuilder.DropTable(name: "AspNetUserLogins");
migrationBuilder.DropTable(name: "AspNetUserRoles"); migrationBuilder.DropTable(name: "AspNetUserRoles");
migrationBuilder.DropTable(name: "AspNetUserTokens"); migrationBuilder.DropTable(name: "AspNetUserTokens");
migrationBuilder.DropTable(name: "RefreshTokens"); migrationBuilder.DropTable(name: "RefreshTokens");
migrationBuilder.DropTable(name: "AspNetRoles"); migrationBuilder.DropTable(name: "AspNetRoles");
migrationBuilder.DropTable(name: "AspNetUsers"); migrationBuilder.DropTable(name: "AspNetUsers");
} }
} }
} }
@@ -1,90 +1,90 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddMediaAssets : Migration public partial class AddMediaAssets : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "MediaAssets", name: "MediaAssets",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
OriginalFileName = table.Column<string>( OriginalFileName = table.Column<string>(
type: "character varying(512)", type: "character varying(512)",
maxLength: 512, maxLength: 512,
nullable: false nullable: false
), ),
OriginalExtension = table.Column<string>( OriginalExtension = table.Column<string>(
type: "character varying(16)", type: "character varying(16)",
maxLength: 16, maxLength: 16,
nullable: false nullable: false
), ),
Source = table.Column<int>(type: "integer", nullable: false), Source = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false), Status = table.Column<int>(type: "integer", nullable: false),
Duration = table.Column<TimeSpan>(type: "interval", nullable: true), Duration = table.Column<TimeSpan>(type: "interval", nullable: true),
SegmentSeconds = table.Column<int>(type: "integer", nullable: true), SegmentSeconds = table.Column<int>(type: "integer", nullable: true),
SegmentCount = table.Column<int>(type: "integer", nullable: true), SegmentCount = table.Column<int>(type: "integer", nullable: true),
Width = table.Column<int>(type: "integer", nullable: true), Width = table.Column<int>(type: "integer", nullable: true),
Height = table.Column<int>(type: "integer", nullable: true), Height = table.Column<int>(type: "integer", nullable: true),
VideoCodec = table.Column<string>( VideoCodec = table.Column<string>(
type: "character varying(32)", type: "character varying(32)",
maxLength: 32, maxLength: 32,
nullable: true nullable: true
), ),
AudioCodec = table.Column<string>( AudioCodec = table.Column<string>(
type: "character varying(32)", type: "character varying(32)",
maxLength: 32, maxLength: 32,
nullable: true nullable: true
), ),
RelativePath = table.Column<string>( RelativePath = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
), ),
ErrorMessage = table.Column<string>( ErrorMessage = table.Column<string>(
type: "character varying(2048)", type: "character varying(2048)",
maxLength: 2048, maxLength: 2048,
nullable: true nullable: true
), ),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
UpdatedAt = table.Column<DateTimeOffset>( UpdatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_MediaAssets", x => x.Id); table.PrimaryKey("PK_MediaAssets", x => x.Id);
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_MediaAssets_CreatedAt", name: "IX_MediaAssets_CreatedAt",
table: "MediaAssets", table: "MediaAssets",
column: "CreatedAt" column: "CreatedAt"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_MediaAssets_Status", name: "IX_MediaAssets_Status",
table: "MediaAssets", table: "MediaAssets",
column: "Status" column: "Status"
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "MediaAssets"); migrationBuilder.DropTable(name: "MediaAssets");
} }
} }
} }
@@ -1,308 +1,308 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddBroadcastAndLibrary : Migration public partial class AddBroadcastAndLibrary : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Channels", name: "Channels",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: false nullable: false
), ),
Slug = table.Column<string>( Slug = table.Column<string>(
type: "character varying(128)", type: "character varying(128)",
maxLength: 128, maxLength: 128,
nullable: false nullable: false
), ),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false), IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
EpochUtc = table.Column<DateTimeOffset>( EpochUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
AdInsertion = table.Column<int>(type: "integer", nullable: false), AdInsertion = table.Column<int>(type: "integer", nullable: false),
AdsPerBreak = table.Column<int>(type: "integer", nullable: false), AdsPerBreak = table.Column<int>(type: "integer", nullable: false),
FillerAssetId = table.Column<Guid>(type: "uuid", nullable: true), FillerAssetId = table.Column<Guid>(type: "uuid", nullable: true),
NextAdIndex = table.Column<int>(type: "integer", nullable: false), NextAdIndex = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Channels", x => x.Id); table.PrimaryKey("PK_Channels", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ScheduleEntries", name: "ScheduleEntries",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false), MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false), Kind = table.Column<int>(type: "integer", nullable: false),
StartsAtUtc = table.Column<DateTimeOffset>( StartsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
EndsAtUtc = table.Column<DateTimeOffset>( EndsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
ShowId = table.Column<Guid>(type: "uuid", nullable: true), ShowId = table.Column<Guid>(type: "uuid", nullable: true),
EpisodeIndex = table.Column<int>(type: "integer", nullable: true), EpisodeIndex = table.Column<int>(type: "integer", nullable: true),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ScheduleEntries", x => x.Id); table.PrimaryKey("PK_ScheduleEntries", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Shows", name: "Shows",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: false nullable: false
), ),
Description = table.Column<string>( Description = table.Column<string>(
type: "character varying(2048)", type: "character varying(2048)",
maxLength: 2048, maxLength: 2048,
nullable: true nullable: true
), ),
Kind = table.Column<int>(type: "integer", nullable: false), Kind = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Shows", x => x.Id); table.PrimaryKey("PK_Shows", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ChannelAd", name: "ChannelAd",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false), MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ChannelAd", x => x.Id); table.PrimaryKey("PK_ChannelAd", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ChannelAd_Channels_ChannelId", name: "FK_ChannelAd_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ChannelShow", name: "ChannelShow",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false), ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false), Weight = table.Column<int>(type: "integer", nullable: false),
BlockMode = table.Column<int>(type: "integer", nullable: false), BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false), BlockValue = table.Column<int>(type: "integer", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false), IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false), NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ChannelShow", x => x.Id); table.PrimaryKey("PK_ChannelShow", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ChannelShow_Channels_ChannelId", name: "FK_ChannelShow_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ProgrammingOverride", name: "ProgrammingOverride",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Mode = table.Column<int>(type: "integer", nullable: false), Mode = table.Column<int>(type: "integer", nullable: false),
StartsAtUtc = table.Column<DateTimeOffset>( StartsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
EndsAtUtc = table.Column<DateTimeOffset>( EndsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id); table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ProgrammingOverride_Channels_ChannelId", name: "FK_ProgrammingOverride_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ShowEpisode", name: "ShowEpisode",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false), ShowId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false), MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ShowEpisode", x => x.Id); table.PrimaryKey("PK_ShowEpisode", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ShowEpisode_Shows_ShowId", name: "FK_ShowEpisode_Shows_ShowId",
column: x => x.ShowId, column: x => x.ShowId,
principalTable: "Shows", principalTable: "Shows",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "OverrideShow", name: "OverrideShow",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false), ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false), ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false), Weight = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_OverrideShow", x => x.Id); table.PrimaryKey("PK_OverrideShow", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId", name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
column: x => x.ProgrammingOverrideId, column: x => x.ProgrammingOverrideId,
principalTable: "ProgrammingOverride", principalTable: "ProgrammingOverride",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ChannelAd_ChannelId_Position", name: "IX_ChannelAd_ChannelId_Position",
table: "ChannelAd", table: "ChannelAd",
columns: new[] { "ChannelId", "Position" } columns: new[] { "ChannelId", "Position" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Channels_Slug", name: "IX_Channels_Slug",
table: "Channels", table: "Channels",
column: "Slug", column: "Slug",
unique: true unique: true
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ChannelShow_ChannelId_ShowId", name: "IX_ChannelShow_ChannelId_ShowId",
table: "ChannelShow", table: "ChannelShow",
columns: new[] { "ChannelId", "ShowId" } columns: new[] { "ChannelId", "ShowId" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_OverrideShow_ProgrammingOverrideId", name: "IX_OverrideShow_ProgrammingOverrideId",
table: "OverrideShow", table: "OverrideShow",
column: "ProgrammingOverrideId" column: "ProgrammingOverrideId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc", name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
table: "ProgrammingOverride", table: "ProgrammingOverride",
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" } columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_EndsAtUtc", name: "IX_ScheduleEntries_ChannelId_EndsAtUtc",
table: "ScheduleEntries", table: "ScheduleEntries",
columns: new[] { "ChannelId", "EndsAtUtc" } columns: new[] { "ChannelId", "EndsAtUtc" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId", name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries", table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId" } columns: new[] { "ChannelId", "ShowId" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_StartsAtUtc", name: "IX_ScheduleEntries_ChannelId_StartsAtUtc",
table: "ScheduleEntries", table: "ScheduleEntries",
columns: new[] { "ChannelId", "StartsAtUtc" } columns: new[] { "ChannelId", "StartsAtUtc" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ShowEpisode_MediaAssetId", name: "IX_ShowEpisode_MediaAssetId",
table: "ShowEpisode", table: "ShowEpisode",
column: "MediaAssetId" column: "MediaAssetId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ShowEpisode_ShowId_Position", name: "IX_ShowEpisode_ShowId_Position",
table: "ShowEpisode", table: "ShowEpisode",
columns: new[] { "ShowId", "Position" } columns: new[] { "ShowId", "Position" }
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "ChannelAd"); migrationBuilder.DropTable(name: "ChannelAd");
migrationBuilder.DropTable(name: "ChannelShow"); migrationBuilder.DropTable(name: "ChannelShow");
migrationBuilder.DropTable(name: "OverrideShow"); migrationBuilder.DropTable(name: "OverrideShow");
migrationBuilder.DropTable(name: "ScheduleEntries"); migrationBuilder.DropTable(name: "ScheduleEntries");
migrationBuilder.DropTable(name: "ShowEpisode"); migrationBuilder.DropTable(name: "ShowEpisode");
migrationBuilder.DropTable(name: "ProgrammingOverride"); migrationBuilder.DropTable(name: "ProgrammingOverride");
migrationBuilder.DropTable(name: "Shows"); migrationBuilder.DropTable(name: "Shows");
migrationBuilder.DropTable(name: "Channels"); migrationBuilder.DropTable(name: "Channels");
} }
} }
} }
@@ -1,61 +1,61 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddBumpers : Migration public partial class AddBumpers : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<bool>( migrationBuilder.AddColumn<bool>(
name: "BumpersEnabled", name: "BumpersEnabled",
table: "Channels", table: "Channels",
type: "boolean", type: "boolean",
nullable: false, nullable: false,
defaultValue: false defaultValue: false
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "BumperAssets", name: "BumperAssets",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
FromShowId = table.Column<Guid>(type: "uuid", nullable: false), FromShowId = table.Column<Guid>(type: "uuid", nullable: false),
ToShowId = table.Column<Guid>(type: "uuid", nullable: false), ToShowId = table.Column<Guid>(type: "uuid", nullable: false),
Signature = table.Column<string>( Signature = table.Column<string>(
type: "character varying(128)", type: "character varying(128)",
maxLength: 128, maxLength: 128,
nullable: false nullable: false
), ),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false), MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_BumperAssets", x => x.Id); table.PrimaryKey("PK_BumperAssets", x => x.Id);
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_BumperAssets_FromShowId_ToShowId_Signature", name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
table: "BumperAssets", table: "BumperAssets",
columns: new[] { "FromShowId", "ToShowId", "Signature" } columns: new[] { "FromShowId", "ToShowId", "Signature" }
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "BumperAssets"); migrationBuilder.DropTable(name: "BumperAssets");
migrationBuilder.DropColumn(name: "BumpersEnabled", table: "Channels"); migrationBuilder.DropColumn(name: "BumpersEnabled", table: "Channels");
} }
} }
} }
@@ -1,118 +1,118 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ChannelBumperSettings : Migration public partial class ChannelBumperSettings : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperAccentColor", name: "BumperAccentColor",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "0x38bdf8" defaultValue: "0x38bdf8"
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor", name: "BumperBackgroundColor",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "0x0b1020" defaultValue: "0x0b1020"
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor2", name: "BumperBackgroundColor2",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "0x1e293b" defaultValue: "0x1e293b"
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperDurationSeconds", name: "BumperDurationSeconds",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 8 defaultValue: 8
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperFont", name: "BumperFont",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperMinIntervalMinutes", name: "BumperMinIntervalMinutes",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperNextLabel", name: "BumperNextLabel",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "ДАЛЕЕ" defaultValue: "ДАЛЕЕ"
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperNowLabel", name: "BumperNowLabel",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "СЕЙЧАС" defaultValue: "СЕЙЧАС"
); );
migrationBuilder.AddColumn<bool>( migrationBuilder.AddColumn<bool>(
name: "BumperOnlyBetweenDifferentShows", name: "BumperOnlyBetweenDifferentShows",
table: "Channels", table: "Channels",
type: "boolean", type: "boolean",
nullable: false, nullable: false,
defaultValue: true defaultValue: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperTextColor", name: "BumperTextColor",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "white" defaultValue: "white"
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels"); migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels"); migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels"); migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels"); migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
migrationBuilder.DropColumn(name: "BumperFont", table: "Channels"); migrationBuilder.DropColumn(name: "BumperFont", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels"); migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels"); migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels"); migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels"); migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels"); migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
} }
} }
} }
@@ -1,97 +1,97 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class BumperFilesAndJingles : Migration public partial class BumperFilesAndJingles : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperBackgroundExtension", name: "BumperBackgroundExtension",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperMode", name: "BumperMode",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperMusicExtension", name: "BumperMusicExtension",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperRevision", name: "BumperRevision",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "NextJingleIndex", name: "NextJingleIndex",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ChannelJingle", name: "ChannelJingle",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false), MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ChannelJingle", x => x.Id); table.PrimaryKey("PK_ChannelJingle", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ChannelJingle_Channels_ChannelId", name: "FK_ChannelJingle_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ChannelJingle_ChannelId_Position", name: "IX_ChannelJingle_ChannelId_Position",
table: "ChannelJingle", table: "ChannelJingle",
columns: new[] { "ChannelId", "Position" } columns: new[] { "ChannelId", "Position" }
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "ChannelJingle"); migrationBuilder.DropTable(name: "ChannelJingle");
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels"); migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels"); migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels"); migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels"); migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
migrationBuilder.DropColumn(name: "NextJingleIndex", table: "Channels"); migrationBuilder.DropColumn(name: "NextJingleIndex", table: "Channels");
} }
} }
} }
@@ -1,41 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AppSettings : Migration public partial class AppSettings : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "AppSettings", name: "AppSettings",
columns: table => new columns: table => new
{ {
Key = table.Column<string>( Key = table.Column<string>(
type: "character varying(128)", type: "character varying(128)",
maxLength: 128, maxLength: 128,
nullable: false nullable: false
), ),
Value = table.Column<string>( Value = table.Column<string>(
type: "character varying(1024)", type: "character varying(1024)",
maxLength: 1024, maxLength: 1024,
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_AppSettings", x => x.Key); table.PrimaryKey("PK_AppSettings", x => x.Key);
} }
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "AppSettings"); migrationBuilder.DropTable(name: "AppSettings");
} }
} }
} }
@@ -1,57 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ShowMetadata : Migration public partial class ShowMetadata : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "MetadataExternalId", name: "MetadataExternalId",
table: "Shows", table: "Shows",
type: "character varying(64)", type: "character varying(64)",
maxLength: 64, maxLength: 64,
nullable: true nullable: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "MetadataProvider", name: "MetadataProvider",
table: "Shows", table: "Shows",
type: "character varying(16)", type: "character varying(16)",
maxLength: 16, maxLength: 16,
nullable: true nullable: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "PosterPath", name: "PosterPath",
table: "Shows", table: "Shows",
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "Year", name: "Year",
table: "Shows", table: "Shows",
type: "integer", type: "integer",
nullable: true nullable: true
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "MetadataExternalId", table: "Shows"); migrationBuilder.DropColumn(name: "MetadataExternalId", table: "Shows");
migrationBuilder.DropColumn(name: "MetadataProvider", table: "Shows"); migrationBuilder.DropColumn(name: "MetadataProvider", table: "Shows");
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows"); migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
migrationBuilder.DropColumn(name: "Year", table: "Shows"); migrationBuilder.DropColumn(name: "Year", table: "Shows");
} }
} }
} }
@@ -1,76 +1,76 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class EpisodeMetadata : Migration public partial class EpisodeMetadata : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<DateOnly>( migrationBuilder.AddColumn<DateOnly>(
name: "AirDate", name: "AirDate",
table: "ShowEpisode", table: "ShowEpisode",
type: "date", type: "date",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "Episode", name: "Episode",
table: "ShowEpisode", table: "ShowEpisode",
type: "integer", type: "integer",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "Overview", name: "Overview",
table: "ShowEpisode", table: "ShowEpisode",
type: "character varying(4096)", type: "character varying(4096)",
maxLength: 4096, maxLength: 4096,
nullable: true nullable: true
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "Season", name: "Season",
table: "ShowEpisode", table: "ShowEpisode",
type: "integer", type: "integer",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "StillPath", name: "StillPath",
table: "ShowEpisode", table: "ShowEpisode",
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "Title", name: "Title",
table: "ShowEpisode", table: "ShowEpisode",
type: "character varying(512)", type: "character varying(512)",
maxLength: 512, maxLength: 512,
nullable: true nullable: true
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "AirDate", table: "ShowEpisode"); migrationBuilder.DropColumn(name: "AirDate", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Episode", table: "ShowEpisode"); migrationBuilder.DropColumn(name: "Episode", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Overview", table: "ShowEpisode"); migrationBuilder.DropColumn(name: "Overview", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Season", table: "ShowEpisode"); migrationBuilder.DropColumn(name: "Season", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode"); migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Title", table: "ShowEpisode"); migrationBuilder.DropColumn(name: "Title", table: "ShowEpisode");
} }
} }
} }
@@ -1,246 +1,246 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class BumperTemplates : Migration public partial class BumperTemplates : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "ChannelJingle"); migrationBuilder.DropTable(name: "ChannelJingle");
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels"); migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels"); migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels"); migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels"); migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels"); migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels"); migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels"); migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels"); migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
name: "NextJingleIndex", name: "NextJingleIndex",
table: "Channels", table: "Channels",
newName: "NextBumperIndex" newName: "NextBumperIndex"
); );
// Ревизия файлов уехала на блоки заставок — старый счётчик не переносим, стратегия по // Ревизия файлов уехала на блоки заставок — старый счётчик не переносим, стратегия по
// умолчанию Rotation (0). // умолчанию Rotation (0).
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels"); migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperSelection", name: "BumperSelection",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "BumperTemplate", name: "BumperTemplate",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(64)", type: "character varying(64)",
maxLength: 64, maxLength: 64,
nullable: false nullable: false
), ),
BackgroundColor = table.Column<string>( BackgroundColor = table.Column<string>(
type: "character varying(32)", type: "character varying(32)",
maxLength: 32, maxLength: 32,
nullable: false nullable: false
), ),
BackgroundColor2 = table.Column<string>( BackgroundColor2 = table.Column<string>(
type: "character varying(32)", type: "character varying(32)",
maxLength: 32, maxLength: 32,
nullable: false nullable: false
), ),
AccentColor = table.Column<string>( AccentColor = table.Column<string>(
type: "character varying(32)", type: "character varying(32)",
maxLength: 32, maxLength: 32,
nullable: false nullable: false
), ),
TextColor = table.Column<string>( TextColor = table.Column<string>(
type: "character varying(32)", type: "character varying(32)",
maxLength: 32, maxLength: 32,
nullable: false nullable: false
), ),
BackgroundImageExtension = table.Column<string>( BackgroundImageExtension = table.Column<string>(
type: "character varying(16)", type: "character varying(16)",
maxLength: 16, maxLength: 16,
nullable: true nullable: true
), ),
AudioExtension = table.Column<string>( AudioExtension = table.Column<string>(
type: "character varying(16)", type: "character varying(16)",
maxLength: 16, maxLength: 16,
nullable: true nullable: true
), ),
AudioDurationSeconds = table.Column<double>( AudioDurationSeconds = table.Column<double>(
type: "double precision", type: "double precision",
nullable: true nullable: true
), ),
Revision = table.Column<int>(type: "integer", nullable: false), Revision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_BumperTemplate", x => x.Id); table.PrimaryKey("PK_BumperTemplate", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_BumperTemplate_Channels_ChannelId", name: "FK_BumperTemplate_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_BumperTemplate_ChannelId_Position", name: "IX_BumperTemplate_ChannelId_Position",
table: "BumperTemplate", table: "BumperTemplate",
columns: new[] { "ChannelId", "Position" } columns: new[] { "ChannelId", "Position" }
); );
// Каждому существующему каналу — дефолтный блок заставки (Position 0, без звука/фона). // Каждому существующему каналу — дефолтный блок заставки (Position 0, без звука/фона).
migrationBuilder.Sql( migrationBuilder.Sql(
""" """
INSERT INTO "BumperTemplate" INSERT INTO "BumperTemplate"
("Id", "ChannelId", "Position", "Name", "BackgroundColor", "BackgroundColor2", ("Id", "ChannelId", "Position", "Name", "BackgroundColor", "BackgroundColor2",
"AccentColor", "TextColor", "Revision", "CreatedAt") "AccentColor", "TextColor", "Revision", "CreatedAt")
SELECT gen_random_uuid(), c."Id", 0, 'Заставка 1', '0x0b1020', '0x1e293b', SELECT gen_random_uuid(), c."Id", 0, 'Заставка 1', '0x0b1020', '0x1e293b',
'0x38bdf8', 'white', 0, now() '0x38bdf8', 'white', 0, now()
FROM "Channels" c; FROM "Channels" c;
""" """
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "BumperTemplate"); migrationBuilder.DropTable(name: "BumperTemplate");
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
name: "NextBumperIndex", name: "NextBumperIndex",
table: "Channels", table: "Channels",
newName: "NextJingleIndex" newName: "NextJingleIndex"
); );
migrationBuilder.DropColumn(name: "BumperSelection", table: "Channels"); migrationBuilder.DropColumn(name: "BumperSelection", table: "Channels");
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperRevision", name: "BumperRevision",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperAccentColor", name: "BumperAccentColor",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "" defaultValue: ""
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor", name: "BumperBackgroundColor",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "" defaultValue: ""
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor2", name: "BumperBackgroundColor2",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "" defaultValue: ""
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperBackgroundExtension", name: "BumperBackgroundExtension",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperDurationSeconds", name: "BumperDurationSeconds",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperMode", name: "BumperMode",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperMusicExtension", name: "BumperMusicExtension",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperTextColor", name: "BumperTextColor",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "" defaultValue: ""
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ChannelJingle", name: "ChannelJingle",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false), MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ChannelJingle", x => x.Id); table.PrimaryKey("PK_ChannelJingle", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ChannelJingle_Channels_ChannelId", name: "FK_ChannelJingle_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ChannelJingle_ChannelId_Position", name: "IX_ChannelJingle_ChannelId_Position",
table: "ChannelJingle", table: "ChannelJingle",
columns: new[] { "ChannelId", "Position" } columns: new[] { "ChannelId", "Position" }
); );
} }
} }
} }
@@ -1,27 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ShowOriginalName : Migration public partial class ShowOriginalName : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "OriginalName", name: "OriginalName",
table: "Shows", table: "Shows",
type: "text", type: "text",
nullable: true nullable: true
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "OriginalName", table: "Shows"); migrationBuilder.DropColumn(name: "OriginalName", table: "Shows");
} }
} }
} }
@@ -1,54 +1,54 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ImageRegistry : Migration public partial class ImageRegistry : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Images", name: "Images",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
Category = table.Column<int>(type: "integer", nullable: false), Category = table.Column<int>(type: "integer", nullable: false),
FileExtension = table.Column<string>( FileExtension = table.Column<string>(
type: "character varying(16)", type: "character varying(16)",
maxLength: 16, maxLength: 16,
nullable: false nullable: false
), ),
OriginalFileName = table.Column<string>( OriginalFileName = table.Column<string>(
type: "character varying(512)", type: "character varying(512)",
maxLength: 512, maxLength: 512,
nullable: true nullable: true
), ),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Images", x => x.Id); table.PrimaryKey("PK_Images", x => x.Id);
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Images_Category_CreatedAt", name: "IX_Images_Category_CreatedAt",
table: "Images", table: "Images",
columns: new[] { "Category", "CreatedAt" } columns: new[] { "Category", "CreatedAt" }
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "Images"); migrationBuilder.DropTable(name: "Images");
} }
} }
} }
@@ -1,78 +1,78 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ShowPosterImage : Migration public partial class ShowPosterImage : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
name: "OriginalName", name: "OriginalName",
table: "Shows", table: "Shows",
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true, nullable: true,
oldClrType: typeof(string), oldClrType: typeof(string),
oldType: "text", oldType: "text",
oldNullable: true oldNullable: true
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "PosterImageId", name: "PosterImageId",
table: "Shows", table: "Shows",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
// Переносим существующие постеры шоу в общий реестр изображений: на каждый постер — // Переносим существующие постеры шоу в общий реестр изображений: на каждый постер —
// запись Images (Category=1 ShowPoster) с расширением из старого пути; файлы перекладывает // запись Images (Category=1 ShowPoster) с расширением из старого пути; файлы перекладывает
// startup-шаг RelocateLegacyImagesAsync. Затем удаляем колонку PosterPath. // startup-шаг RelocateLegacyImagesAsync. Затем удаляем колонку PosterPath.
migrationBuilder.Sql( migrationBuilder.Sql(
""" """
DO $$ DO $$
DECLARE r RECORD; img uuid; DECLARE r RECORD; img uuid;
BEGIN BEGIN
FOR r IN SELECT "Id", "PosterPath" FROM "Shows" WHERE "PosterPath" IS NOT NULL LOOP FOR r IN SELECT "Id", "PosterPath" FROM "Shows" WHERE "PosterPath" IS NOT NULL LOOP
img := gen_random_uuid(); img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt") INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 1, lower(coalesce(substring(r."PosterPath" from '\.[^.]*$'), '.jpg')), 'poster', now()); VALUES (img, 1, lower(coalesce(substring(r."PosterPath" from '\.[^.]*$'), '.jpg')), 'poster', now());
UPDATE "Shows" SET "PosterImageId" = img WHERE "Id" = r."Id"; UPDATE "Shows" SET "PosterImageId" = img WHERE "Id" = r."Id";
END LOOP; END LOOP;
END $$; END $$;
""" """
); );
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows"); migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "PosterImageId", table: "Shows"); migrationBuilder.DropColumn(name: "PosterImageId", table: "Shows");
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
name: "OriginalName", name: "OriginalName",
table: "Shows", table: "Shows",
type: "text", type: "text",
nullable: true, nullable: true,
oldClrType: typeof(string), oldClrType: typeof(string),
oldType: "character varying(256)", oldType: "character varying(256)",
oldMaxLength: 256, oldMaxLength: 256,
oldNullable: true oldNullable: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "PosterPath", name: "PosterPath",
table: "Shows", table: "Shows",
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
); );
} }
} }
} }
@@ -1,89 +1,89 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class EpisodeStillAndBumperBgImages : Migration public partial class EpisodeStillAndBumperBgImages : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "StillImageId", name: "StillImageId",
table: "ShowEpisode", table: "ShowEpisode",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "BackgroundImageId", name: "BackgroundImageId",
table: "BumperTemplate", table: "BumperTemplate",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
// Кадры серий → реестр (Category=2 EpisodeStill); файлы перекладывает RelocateLegacyImagesAsync. // Кадры серий → реестр (Category=2 EpisodeStill); файлы перекладывает RelocateLegacyImagesAsync.
migrationBuilder.Sql( migrationBuilder.Sql(
""" """
DO $$ DO $$
DECLARE r RECORD; img uuid; DECLARE r RECORD; img uuid;
BEGIN BEGIN
FOR r IN SELECT "Id", "StillPath" FROM "ShowEpisode" WHERE "StillPath" IS NOT NULL LOOP FOR r IN SELECT "Id", "StillPath" FROM "ShowEpisode" WHERE "StillPath" IS NOT NULL LOOP
img := gen_random_uuid(); img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt") INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 2, lower(coalesce(substring(r."StillPath" from '\.[^.]*$'), '.jpg')), 'still', now()); VALUES (img, 2, lower(coalesce(substring(r."StillPath" from '\.[^.]*$'), '.jpg')), 'still', now());
UPDATE "ShowEpisode" SET "StillImageId" = img WHERE "Id" = r."Id"; UPDATE "ShowEpisode" SET "StillImageId" = img WHERE "Id" = r."Id";
END LOOP; END LOOP;
END $$; END $$;
""" """
); );
// Фоны блоков заставок → реестр (Category=3 BumperBackground). // Фоны блоков заставок → реестр (Category=3 BumperBackground).
migrationBuilder.Sql( migrationBuilder.Sql(
""" """
DO $$ DO $$
DECLARE r RECORD; img uuid; DECLARE r RECORD; img uuid;
BEGIN BEGIN
FOR r IN SELECT "Id", "BackgroundImageExtension" FROM "BumperTemplate" WHERE "BackgroundImageExtension" IS NOT NULL LOOP FOR r IN SELECT "Id", "BackgroundImageExtension" FROM "BumperTemplate" WHERE "BackgroundImageExtension" IS NOT NULL LOOP
img := gen_random_uuid(); img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt") INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 3, lower(r."BackgroundImageExtension"), 'background', now()); VALUES (img, 3, lower(r."BackgroundImageExtension"), 'background', now());
UPDATE "BumperTemplate" SET "BackgroundImageId" = img WHERE "Id" = r."Id"; UPDATE "BumperTemplate" SET "BackgroundImageId" = img WHERE "Id" = r."Id";
END LOOP; END LOOP;
END $$; END $$;
""" """
); );
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode"); migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "BackgroundImageExtension", table: "BumperTemplate"); migrationBuilder.DropColumn(name: "BackgroundImageExtension", table: "BumperTemplate");
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "StillImageId", table: "ShowEpisode"); migrationBuilder.DropColumn(name: "StillImageId", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "BackgroundImageId", table: "BumperTemplate"); migrationBuilder.DropColumn(name: "BackgroundImageId", table: "BumperTemplate");
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "StillPath", name: "StillPath",
table: "ShowEpisode", table: "ShowEpisode",
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: true nullable: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BackgroundImageExtension", name: "BackgroundImageExtension",
table: "BumperTemplate", table: "BumperTemplate",
type: "character varying(16)", type: "character varying(16)",
maxLength: 16, maxLength: 16,
nullable: true nullable: true
); );
} }
} }
} }
@@ -1,131 +1,131 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class BumperTextVariants : Migration public partial class BumperTextVariants : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "BumperTextVariant", name: "BumperTextVariant",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: false), BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(64)", type: "character varying(64)",
maxLength: 64, maxLength: 64,
nullable: false nullable: false
), ),
Kind = table.Column<int>(type: "integer", nullable: false), Kind = table.Column<int>(type: "integer", nullable: false),
NowLabel = table.Column<string>( NowLabel = table.Column<string>(
type: "character varying(64)", type: "character varying(64)",
maxLength: 64, maxLength: 64,
nullable: false nullable: false
), ),
NextLabel = table.Column<string>( NextLabel = table.Column<string>(
type: "character varying(64)", type: "character varying(64)",
maxLength: 64, maxLength: 64,
nullable: false nullable: false
), ),
Line1 = table.Column<string>( Line1 = table.Column<string>(
type: "character varying(120)", type: "character varying(120)",
maxLength: 120, maxLength: 120,
nullable: false nullable: false
), ),
Line2 = table.Column<string>( Line2 = table.Column<string>(
type: "character varying(120)", type: "character varying(120)",
maxLength: 120, maxLength: 120,
nullable: false nullable: false
), ),
Trigger = table.Column<int>(type: "integer", nullable: false), Trigger = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_BumperTextVariant", x => x.Id); table.PrimaryKey("PK_BumperTextVariant", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId", name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
column: x => x.BumperTemplateId, column: x => x.BumperTemplateId,
principalTable: "BumperTemplate", principalTable: "BumperTemplate",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_BumperTextVariant_BumperTemplateId_Position", name: "IX_BumperTextVariant_BumperTemplateId_Position",
table: "BumperTextVariant", table: "BumperTextVariant",
columns: new[] { "BumperTemplateId", "Position" } columns: new[] { "BumperTemplateId", "Position" }
); );
// Каждому блоку — дефолтный подблок «Сейчас/Далее» с прежними подписями канала; правило // Каждому блоку — дефолтный подблок «Сейчас/Далее» с прежними подписями канала; правило
// показа переносим из старой галочки (только на смене шоу → OnShowChange=0, иначе Both=2). // показа переносим из старой галочки (только на смене шоу → OnShowChange=0, иначе Both=2).
migrationBuilder.Sql( migrationBuilder.Sql(
""" """
DO $$ DO $$
DECLARE r RECORD; DECLARE r RECORD;
BEGIN BEGIN
FOR r IN FOR r IN
SELECT t."Id" AS tid, c."BumperNowLabel" AS nl, c."BumperNextLabel" AS xl, SELECT t."Id" AS tid, c."BumperNowLabel" AS nl, c."BumperNextLabel" AS xl,
c."BumperOnlyBetweenDifferentShows" AS only_diff c."BumperOnlyBetweenDifferentShows" AS only_diff
FROM "BumperTemplate" t FROM "BumperTemplate" t
JOIN "Channels" c ON c."Id" = t."ChannelId" JOIN "Channels" c ON c."Id" = t."ChannelId"
LOOP LOOP
INSERT INTO "BumperTextVariant" INSERT INTO "BumperTextVariant"
("Id", "BumperTemplateId", "Position", "Name", "Kind", ("Id", "BumperTemplateId", "Position", "Name", "Kind",
"NowLabel", "NextLabel", "Line1", "Line2", "Trigger", "CreatedAt") "NowLabel", "NextLabel", "Line1", "Line2", "Trigger", "CreatedAt")
VALUES (gen_random_uuid(), r.tid, 0, 'Текст 1', 0, VALUES (gen_random_uuid(), r.tid, 0, 'Текст 1', 0,
COALESCE(NULLIF(r.nl, ''), 'СЕЙЧАС'), COALESCE(NULLIF(r.xl, ''), 'ДАЛЕЕ'), COALESCE(NULLIF(r.nl, ''), 'СЕЙЧАС'), COALESCE(NULLIF(r.xl, ''), 'ДАЛЕЕ'),
'', '', CASE WHEN r.only_diff THEN 0 ELSE 2 END, now()); '', '', CASE WHEN r.only_diff THEN 0 ELSE 2 END, now());
END LOOP; END LOOP;
END $$; END $$;
""" """
); );
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels"); migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels"); migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels"); migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "BumperTextVariant"); migrationBuilder.DropTable(name: "BumperTextVariant");
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperNextLabel", name: "BumperNextLabel",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "" defaultValue: ""
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "BumperNowLabel", name: "BumperNowLabel",
table: "Channels", table: "Channels",
type: "text", type: "text",
nullable: false, nullable: false,
defaultValue: "" defaultValue: ""
); );
migrationBuilder.AddColumn<bool>( migrationBuilder.AddColumn<bool>(
name: "BumperOnlyBetweenDifferentShows", name: "BumperOnlyBetweenDifferentShows",
table: "Channels", table: "Channels",
type: "boolean", type: "boolean",
nullable: false, nullable: false,
defaultValue: false defaultValue: false
); );
} }
} }
} }
@@ -1,125 +1,125 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class BumperChancesWeightsAndScheduleVariant : Migration public partial class BumperChancesWeightsAndScheduleVariant : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId", name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
table: "BumperTextVariant" table: "BumperTextVariant"
); );
migrationBuilder.DropPrimaryKey( migrationBuilder.DropPrimaryKey(
name: "PK_BumperTextVariant", name: "PK_BumperTextVariant",
table: "BumperTextVariant" table: "BumperTextVariant"
); );
migrationBuilder.RenameTable(name: "BumperTextVariant", newName: "BumperTextVariants"); migrationBuilder.RenameTable(name: "BumperTextVariant", newName: "BumperTextVariants");
migrationBuilder.RenameIndex( migrationBuilder.RenameIndex(
name: "IX_BumperTextVariant_BumperTemplateId_Position", name: "IX_BumperTextVariant_BumperTemplateId_Position",
table: "BumperTextVariants", table: "BumperTextVariants",
newName: "IX_BumperTextVariants_BumperTemplateId_Position" newName: "IX_BumperTextVariants_BumperTemplateId_Position"
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "BumperVariantId", name: "BumperVariantId",
table: "ScheduleEntries", table: "ScheduleEntries",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
// Существующим каналам — 1.0 (заставка на каждом подходящем переходе, как было до фичи). // Существующим каналам — 1.0 (заставка на каждом подходящем переходе, как было до фичи).
migrationBuilder.AddColumn<double>( migrationBuilder.AddColumn<double>(
name: "BumperEpisodeChangeChance", name: "BumperEpisodeChangeChance",
table: "Channels", table: "Channels",
type: "double precision", type: "double precision",
nullable: false, nullable: false,
defaultValue: 1.0 defaultValue: 1.0
); );
migrationBuilder.AddColumn<double>( migrationBuilder.AddColumn<double>(
name: "BumperShowChangeChance", name: "BumperShowChangeChance",
table: "Channels", table: "Channels",
type: "double precision", type: "double precision",
nullable: false, nullable: false,
defaultValue: 1.0 defaultValue: 1.0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "Weight", name: "Weight",
table: "BumperTextVariants", table: "BumperTextVariants",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 1 defaultValue: 1
); );
migrationBuilder.AddPrimaryKey( migrationBuilder.AddPrimaryKey(
name: "PK_BumperTextVariants", name: "PK_BumperTextVariants",
table: "BumperTextVariants", table: "BumperTextVariants",
column: "Id" column: "Id"
); );
migrationBuilder.AddForeignKey( migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId", name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants", table: "BumperTextVariants",
column: "BumperTemplateId", column: "BumperTemplateId",
principalTable: "BumperTemplate", principalTable: "BumperTemplate",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId", name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants" table: "BumperTextVariants"
); );
migrationBuilder.DropPrimaryKey( migrationBuilder.DropPrimaryKey(
name: "PK_BumperTextVariants", name: "PK_BumperTextVariants",
table: "BumperTextVariants" table: "BumperTextVariants"
); );
migrationBuilder.DropColumn(name: "BumperVariantId", table: "ScheduleEntries"); migrationBuilder.DropColumn(name: "BumperVariantId", table: "ScheduleEntries");
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels"); migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels"); migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "Weight", table: "BumperTextVariants"); migrationBuilder.DropColumn(name: "Weight", table: "BumperTextVariants");
migrationBuilder.RenameTable(name: "BumperTextVariants", newName: "BumperTextVariant"); migrationBuilder.RenameTable(name: "BumperTextVariants", newName: "BumperTextVariant");
migrationBuilder.RenameIndex( migrationBuilder.RenameIndex(
name: "IX_BumperTextVariants_BumperTemplateId_Position", name: "IX_BumperTextVariants_BumperTemplateId_Position",
table: "BumperTextVariant", table: "BumperTextVariant",
newName: "IX_BumperTextVariant_BumperTemplateId_Position" newName: "IX_BumperTextVariant_BumperTemplateId_Position"
); );
migrationBuilder.AddPrimaryKey( migrationBuilder.AddPrimaryKey(
name: "PK_BumperTextVariant", name: "PK_BumperTextVariant",
table: "BumperTextVariant", table: "BumperTextVariant",
column: "Id" column: "Id"
); );
migrationBuilder.AddForeignKey( migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId", name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
table: "BumperTextVariant", table: "BumperTextVariant",
column: "BumperTemplateId", column: "BumperTemplateId",
principalTable: "BumperTemplate", principalTable: "BumperTemplate",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
} }
} }
@@ -1,59 +1,59 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ChannelShowPreferredHours : Migration public partial class ChannelShowPreferredHours : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "PreferredWeightMultiplier", name: "PreferredWeightMultiplier",
table: "ChannelShow", table: "ChannelShow",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 3 defaultValue: 3
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ChannelShowHour", name: "ChannelShowHour",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false), ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
StartHour = table.Column<int>(type: "integer", nullable: false), StartHour = table.Column<int>(type: "integer", nullable: false),
EndHour = table.Column<int>(type: "integer", nullable: false), EndHour = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ChannelShowHour", x => x.Id); table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId", name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
column: x => x.ChannelShowId, column: x => x.ChannelShowId,
principalTable: "ChannelShow", principalTable: "ChannelShow",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ChannelShowHour_ChannelShowId", name: "IX_ChannelShowHour_ChannelShowId",
table: "ChannelShowHour", table: "ChannelShowHour",
column: "ChannelShowId" column: "ChannelShowId"
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "ChannelShowHour"); migrationBuilder.DropTable(name: "ChannelShowHour");
migrationBuilder.DropColumn(name: "PreferredWeightMultiplier", table: "ChannelShow"); migrationBuilder.DropColumn(name: "PreferredWeightMultiplier", table: "ChannelShow");
} }
} }
} }
@@ -1,102 +1,102 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class WeeklyProgrammingOverrides : Migration public partial class WeeklyProgrammingOverrides : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<DateTimeOffset>( migrationBuilder.AlterColumn<DateTimeOffset>(
name: "StartsAtUtc", name: "StartsAtUtc",
table: "ProgrammingOverride", table: "ProgrammingOverride",
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: true, nullable: true,
oldClrType: typeof(DateTimeOffset), oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone" oldType: "timestamp with time zone"
); );
migrationBuilder.AlterColumn<DateTimeOffset>( migrationBuilder.AlterColumn<DateTimeOffset>(
name: "EndsAtUtc", name: "EndsAtUtc",
table: "ProgrammingOverride", table: "ProgrammingOverride",
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: true, nullable: true,
oldClrType: typeof(DateTimeOffset), oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone" oldType: "timestamp with time zone"
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "DayOfWeek", name: "DayOfWeek",
table: "ProgrammingOverride", table: "ProgrammingOverride",
type: "integer", type: "integer",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "EndMinute", name: "EndMinute",
table: "ProgrammingOverride", table: "ProgrammingOverride",
type: "integer", type: "integer",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "Recurrence", name: "Recurrence",
table: "ProgrammingOverride", table: "ProgrammingOverride",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "StartMinute", name: "StartMinute",
table: "ProgrammingOverride", table: "ProgrammingOverride",
type: "integer", type: "integer",
nullable: true nullable: true
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "DayOfWeek", table: "ProgrammingOverride"); migrationBuilder.DropColumn(name: "DayOfWeek", table: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "EndMinute", table: "ProgrammingOverride"); migrationBuilder.DropColumn(name: "EndMinute", table: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "Recurrence", table: "ProgrammingOverride"); migrationBuilder.DropColumn(name: "Recurrence", table: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "StartMinute", table: "ProgrammingOverride"); migrationBuilder.DropColumn(name: "StartMinute", table: "ProgrammingOverride");
migrationBuilder.AlterColumn<DateTimeOffset>( migrationBuilder.AlterColumn<DateTimeOffset>(
name: "StartsAtUtc", name: "StartsAtUtc",
table: "ProgrammingOverride", table: "ProgrammingOverride",
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false, nullable: false,
defaultValue: new DateTimeOffset( defaultValue: new DateTimeOffset(
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
new TimeSpan(0, 0, 0, 0, 0) new TimeSpan(0, 0, 0, 0, 0)
), ),
oldClrType: typeof(DateTimeOffset), oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone", oldType: "timestamp with time zone",
oldNullable: true oldNullable: true
); );
migrationBuilder.AlterColumn<DateTimeOffset>( migrationBuilder.AlterColumn<DateTimeOffset>(
name: "EndsAtUtc", name: "EndsAtUtc",
table: "ProgrammingOverride", table: "ProgrammingOverride",
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false, nullable: false,
defaultValue: new DateTimeOffset( defaultValue: new DateTimeOffset(
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
new TimeSpan(0, 0, 0, 0, 0) new TimeSpan(0, 0, 0, 0, 0)
), ),
oldClrType: typeof(DateTimeOffset), oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone", oldType: "timestamp with time zone",
oldNullable: true oldNullable: true
); );
} }
} }
} }
@@ -1,49 +1,49 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class BumperAssetRenderContext : Migration public partial class BumperAssetRenderContext : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "ChannelId", name: "ChannelId",
table: "BumperAssets", table: "BumperAssets",
type: "uuid", type: "uuid",
nullable: false, nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000") defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "TemplateId", name: "TemplateId",
table: "BumperAssets", table: "BumperAssets",
type: "uuid", type: "uuid",
nullable: false, nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000") defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "VariantId", name: "VariantId",
table: "BumperAssets", table: "BumperAssets",
type: "uuid", type: "uuid",
nullable: false, nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000") defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets"); migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "TemplateId", table: "BumperAssets"); migrationBuilder.DropColumn(name: "TemplateId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "VariantId", table: "BumperAssets"); migrationBuilder.DropColumn(name: "VariantId", table: "BumperAssets");
} }
} }
} }
@@ -1,37 +1,37 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class MediaProcessingTiming : Migration public partial class MediaProcessingTiming : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<TimeSpan>( migrationBuilder.AddColumn<TimeSpan>(
name: "ProcessingDuration", name: "ProcessingDuration",
table: "MediaAssets", table: "MediaAssets",
type: "interval", type: "interval",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<DateTimeOffset>( migrationBuilder.AddColumn<DateTimeOffset>(
name: "ProcessingStartedAt", name: "ProcessingStartedAt",
table: "MediaAssets", table: "MediaAssets",
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: true nullable: true
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "ProcessingDuration", table: "MediaAssets"); migrationBuilder.DropColumn(name: "ProcessingDuration", table: "MediaAssets");
migrationBuilder.DropColumn(name: "ProcessingStartedAt", table: "MediaAssets"); migrationBuilder.DropColumn(name: "ProcessingStartedAt", table: "MediaAssets");
} }
} }
} }
@@ -1,28 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ShowAudience : Migration public partial class ShowAudience : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "Audience", name: "Audience",
table: "Shows", table: "Shows",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "Audience", table: "Shows"); migrationBuilder.DropColumn(name: "Audience", table: "Shows");
} }
} }
} }
@@ -1,132 +1,132 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddGenres : Migration public partial class AddGenres : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Genres", name: "Genres",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(128)", type: "character varying(128)",
maxLength: 128, maxLength: 128,
nullable: false nullable: false
), ),
Slug = table.Column<string>( Slug = table.Column<string>(
type: "character varying(64)", type: "character varying(64)",
maxLength: 64, maxLength: 64,
nullable: false nullable: false
), ),
SortOrder = table.Column<int>(type: "integer", nullable: false), SortOrder = table.Column<int>(type: "integer", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false), IsSystem = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Genres", x => x.Id); table.PrimaryKey("PK_Genres", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "GenreAliases", name: "GenreAliases",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
GenreId = table.Column<Guid>(type: "uuid", nullable: false), GenreId = table.Column<Guid>(type: "uuid", nullable: false),
Value = table.Column<string>( Value = table.Column<string>(
type: "character varying(128)", type: "character varying(128)",
maxLength: 128, maxLength: 128,
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_GenreAliases", x => x.Id); table.PrimaryKey("PK_GenreAliases", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_GenreAliases_Genres_GenreId", name: "FK_GenreAliases_Genres_GenreId",
column: x => x.GenreId, column: x => x.GenreId,
principalTable: "Genres", principalTable: "Genres",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ShowGenres", name: "ShowGenres",
columns: table => new columns: table => new
{ {
ShowId = table.Column<Guid>(type: "uuid", nullable: false), ShowId = table.Column<Guid>(type: "uuid", nullable: false),
GenreId = table.Column<Guid>(type: "uuid", nullable: false), GenreId = table.Column<Guid>(type: "uuid", nullable: false),
IsPrimary = table.Column<bool>(type: "boolean", nullable: false), IsPrimary = table.Column<bool>(type: "boolean", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ShowGenres", x => new { x.ShowId, x.GenreId }); table.PrimaryKey("PK_ShowGenres", x => new { x.ShowId, x.GenreId });
table.ForeignKey( table.ForeignKey(
name: "FK_ShowGenres_Genres_GenreId", name: "FK_ShowGenres_Genres_GenreId",
column: x => x.GenreId, column: x => x.GenreId,
principalTable: "Genres", principalTable: "Genres",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Restrict onDelete: ReferentialAction.Restrict
); );
table.ForeignKey( table.ForeignKey(
name: "FK_ShowGenres_Shows_ShowId", name: "FK_ShowGenres_Shows_ShowId",
column: x => x.ShowId, column: x => x.ShowId,
principalTable: "Shows", principalTable: "Shows",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_GenreAliases_GenreId", name: "IX_GenreAliases_GenreId",
table: "GenreAliases", table: "GenreAliases",
column: "GenreId" column: "GenreId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_GenreAliases_Value", name: "IX_GenreAliases_Value",
table: "GenreAliases", table: "GenreAliases",
column: "Value", column: "Value",
unique: true unique: true
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Genres_Slug", name: "IX_Genres_Slug",
table: "Genres", table: "Genres",
column: "Slug", column: "Slug",
unique: true unique: true
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ShowGenres_GenreId", name: "IX_ShowGenres_GenreId",
table: "ShowGenres", table: "ShowGenres",
column: "GenreId" column: "GenreId"
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "GenreAliases"); migrationBuilder.DropTable(name: "GenreAliases");
migrationBuilder.DropTable(name: "ShowGenres"); migrationBuilder.DropTable(name: "ShowGenres");
migrationBuilder.DropTable(name: "Genres"); migrationBuilder.DropTable(name: "Genres");
} }
} }
} }
@@ -1,98 +1,98 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddCollections : Migration public partial class AddCollections : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Collections", name: "Collections",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: false nullable: false
), ),
Description = table.Column<string>( Description = table.Column<string>(
type: "character varying(2048)", type: "character varying(2048)",
maxLength: 2048, maxLength: 2048,
nullable: true nullable: true
), ),
PosterImageId = table.Column<Guid>(type: "uuid", nullable: true), PosterImageId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Collections", x => x.Id); table.PrimaryKey("PK_Collections", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "CollectionItems", name: "CollectionItems",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
CollectionId = table.Column<Guid>(type: "uuid", nullable: false), CollectionId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false), ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_CollectionItems", x => x.Id); table.PrimaryKey("PK_CollectionItems", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_CollectionItems_Collections_CollectionId", name: "FK_CollectionItems_Collections_CollectionId",
column: x => x.CollectionId, column: x => x.CollectionId,
principalTable: "Collections", principalTable: "Collections",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
table.ForeignKey( table.ForeignKey(
name: "FK_CollectionItems_Shows_ShowId", name: "FK_CollectionItems_Shows_ShowId",
column: x => x.ShowId, column: x => x.ShowId,
principalTable: "Shows", principalTable: "Shows",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_CollectionItems_CollectionId_Position", name: "IX_CollectionItems_CollectionId_Position",
table: "CollectionItems", table: "CollectionItems",
columns: new[] { "CollectionId", "Position" } columns: new[] { "CollectionId", "Position" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_CollectionItems_CollectionId_ShowId", name: "IX_CollectionItems_CollectionId_ShowId",
table: "CollectionItems", table: "CollectionItems",
columns: new[] { "CollectionId", "ShowId" }, columns: new[] { "CollectionId", "ShowId" },
unique: true unique: true
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_CollectionItems_ShowId", name: "IX_CollectionItems_ShowId",
table: "CollectionItems", table: "CollectionItems",
column: "ShowId" column: "ShowId"
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "CollectionItems"); migrationBuilder.DropTable(name: "CollectionItems");
migrationBuilder.DropTable(name: "Collections"); migrationBuilder.DropTable(name: "Collections");
} }
} }
} }
@@ -1,100 +1,100 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddGroups : Migration public partial class AddGroups : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Groups", name: "Groups",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: false nullable: false
), ),
Description = table.Column<string>( Description = table.Column<string>(
type: "character varying(2048)", type: "character varying(2048)",
maxLength: 2048, maxLength: 2048,
nullable: true nullable: true
), ),
FilterJson = table.Column<string>(type: "jsonb", nullable: true), FilterJson = table.Column<string>(type: "jsonb", nullable: true),
ItemCount = table.Column<int>(type: "integer", nullable: false), ItemCount = table.Column<int>(type: "integer", nullable: false),
UnitCount = table.Column<int>(type: "integer", nullable: false), UnitCount = table.Column<int>(type: "integer", nullable: false),
TotalDuration = table.Column<TimeSpan>(type: "interval", nullable: false), TotalDuration = table.Column<TimeSpan>(type: "interval", nullable: false),
StatsComputedAt = table.Column<DateTimeOffset>( StatsComputedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: true nullable: true
), ),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Groups", x => x.Id); table.PrimaryKey("PK_Groups", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "GroupItems", name: "GroupItems",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: false), GroupId = table.Column<Guid>(type: "uuid", nullable: false),
ElementKind = table.Column<int>(type: "integer", nullable: false), ElementKind = table.Column<int>(type: "integer", nullable: false),
ElementId = table.Column<Guid>(type: "uuid", nullable: false), ElementId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false), Weight = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_GroupItems", x => x.Id); table.PrimaryKey("PK_GroupItems", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_GroupItems_Groups_GroupId", name: "FK_GroupItems_Groups_GroupId",
column: x => x.GroupId, column: x => x.GroupId,
principalTable: "Groups", principalTable: "Groups",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_GroupItems_ElementKind_ElementId", name: "IX_GroupItems_ElementKind_ElementId",
table: "GroupItems", table: "GroupItems",
columns: new[] { "ElementKind", "ElementId" } columns: new[] { "ElementKind", "ElementId" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_GroupItems_GroupId_ElementKind_ElementId", name: "IX_GroupItems_GroupId_ElementKind_ElementId",
table: "GroupItems", table: "GroupItems",
columns: new[] { "GroupId", "ElementKind", "ElementId" }, columns: new[] { "GroupId", "ElementKind", "ElementId" },
unique: true unique: true
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_GroupItems_GroupId_Position", name: "IX_GroupItems_GroupId_Position",
table: "GroupItems", table: "GroupItems",
columns: new[] { "GroupId", "Position" } columns: new[] { "GroupId", "Position" }
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "GroupItems"); migrationBuilder.DropTable(name: "GroupItems");
migrationBuilder.DropTable(name: "Groups"); migrationBuilder.DropTable(name: "Groups");
} }
} }
} }
@@ -1,224 +1,224 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddScheduleTemplate : Migration public partial class AddScheduleTemplate : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<TimeOnly>( migrationBuilder.AddColumn<TimeOnly>(
name: "DayStartTime", name: "DayStartTime",
table: "Channels", table: "Channels",
type: "time without time zone", type: "time without time zone",
nullable: false, nullable: false,
defaultValue: new TimeOnly(0, 0, 0) defaultValue: new TimeOnly(0, 0, 0)
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "Number", name: "Number",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "TemplateId", name: "TemplateId",
table: "Channels", table: "Channels",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "UtcOffsetMinutes", name: "UtcOffsetMinutes",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ScheduleTemplates", name: "ScheduleTemplates",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: false nullable: false
), ),
FallbackGroupId = table.Column<Guid>(type: "uuid", nullable: true), FallbackGroupId = table.Column<Guid>(type: "uuid", nullable: true),
Revision = table.Column<int>(type: "integer", nullable: false), Revision = table.Column<int>(type: "integer", nullable: false),
AppliedRevision = table.Column<int>(type: "integer", nullable: false), AppliedRevision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ScheduleTemplates", x => x.Id); table.PrimaryKey("PK_ScheduleTemplates", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "GridLayers", name: "GridLayers",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
TemplateId = table.Column<Guid>(type: "uuid", nullable: false), TemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(128)", type: "character varying(128)",
maxLength: 128, maxLength: 128,
nullable: false nullable: false
), ),
Priority = table.Column<int>(type: "integer", nullable: false), Priority = table.Column<int>(type: "integer", nullable: false),
ApplicabilityJson = table.Column<string>(type: "jsonb", nullable: true), ApplicabilityJson = table.Column<string>(type: "jsonb", nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false), IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
IsBackground = table.Column<bool>(type: "boolean", nullable: false), IsBackground = table.Column<bool>(type: "boolean", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_GridLayers", x => x.Id); table.PrimaryKey("PK_GridLayers", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_GridLayers_ScheduleTemplates_TemplateId", name: "FK_GridLayers_ScheduleTemplates_TemplateId",
column: x => x.TemplateId, column: x => x.TemplateId,
principalTable: "ScheduleTemplates", principalTable: "ScheduleTemplates",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Slots", name: "Slots",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
LayerId = table.Column<Guid>(type: "uuid", nullable: false), LayerId = table.Column<Guid>(type: "uuid", nullable: false),
Weekday = table.Column<int>(type: "integer", nullable: true), Weekday = table.Column<int>(type: "integer", nullable: true),
TargetStart = table.Column<TimeOnly>( TargetStart = table.Column<TimeOnly>(
type: "time without time zone", type: "time without time zone",
nullable: false nullable: false
), ),
TargetDurationMinutes = table.Column<int>(type: "integer", nullable: false), TargetDurationMinutes = table.Column<int>(type: "integer", nullable: false),
Title = table.Column<string>( Title = table.Column<string>(
type: "character varying(256)", type: "character varying(256)",
maxLength: 256, maxLength: 256,
nullable: false nullable: false
), ),
Daypart = table.Column<int>(type: "integer", nullable: false), Daypart = table.Column<int>(type: "integer", nullable: false),
SlotKind = table.Column<int>(type: "integer", nullable: false), SlotKind = table.Column<int>(type: "integer", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: true), GroupId = table.Column<Guid>(type: "uuid", nullable: true),
StrategyJson = table.Column<string>(type: "jsonb", nullable: true), StrategyJson = table.Column<string>(type: "jsonb", nullable: true),
RepeatSourceJson = table.Column<string>(type: "jsonb", nullable: true), RepeatSourceJson = table.Column<string>(type: "jsonb", nullable: true),
BlockMode = table.Column<int>(type: "integer", nullable: false), BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false), BlockValue = table.Column<int>(type: "integer", nullable: false),
OverflowPolicy = table.Column<int>(type: "integer", nullable: false), OverflowPolicy = table.Column<int>(type: "integer", nullable: false),
IsAnchor = table.Column<bool>(type: "boolean", nullable: false), IsAnchor = table.Column<bool>(type: "boolean", nullable: false),
MaxDriftMinutes = table.Column<int>(type: "integer", nullable: false), MaxDriftMinutes = table.Column<int>(type: "integer", nullable: false),
SnapToMinutes = table.Column<int>(type: "integer", nullable: true), SnapToMinutes = table.Column<int>(type: "integer", nullable: true),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Slots", x => x.Id); table.PrimaryKey("PK_Slots", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_Slots_GridLayers_LayerId", name: "FK_Slots_GridLayers_LayerId",
column: x => x.LayerId, column: x => x.LayerId,
principalTable: "GridLayers", principalTable: "GridLayers",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
table.ForeignKey( table.ForeignKey(
name: "FK_Slots_Groups_GroupId", name: "FK_Slots_Groups_GroupId",
column: x => x.GroupId, column: x => x.GroupId,
principalTable: "Groups", principalTable: "Groups",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Restrict onDelete: ReferentialAction.Restrict
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "SlotStates", name: "SlotStates",
columns: table => new columns: table => new
{ {
SlotId = table.Column<Guid>(type: "uuid", nullable: false), SlotId = table.Column<Guid>(type: "uuid", nullable: false),
CurrentElementKind = table.Column<int>(type: "integer", nullable: true), CurrentElementKind = table.Column<int>(type: "integer", nullable: true),
CurrentElementId = table.Column<Guid>(type: "uuid", nullable: true), CurrentElementId = table.Column<Guid>(type: "uuid", nullable: true),
NextUnitIndex = table.Column<int>(type: "integer", nullable: false), NextUnitIndex = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_SlotStates", x => x.SlotId); table.PrimaryKey("PK_SlotStates", x => x.SlotId);
table.ForeignKey( table.ForeignKey(
name: "FK_SlotStates_Slots_SlotId", name: "FK_SlotStates_Slots_SlotId",
column: x => x.SlotId, column: x => x.SlotId,
principalTable: "Slots", principalTable: "Slots",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Channels_Number", name: "IX_Channels_Number",
table: "Channels", table: "Channels",
column: "Number", column: "Number",
unique: true, unique: true,
filter: "\"Number\" IS NOT NULL" filter: "\"Number\" IS NOT NULL"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_GridLayers_TemplateId_Priority", name: "IX_GridLayers_TemplateId_Priority",
table: "GridLayers", table: "GridLayers",
columns: new[] { "TemplateId", "Priority" } columns: new[] { "TemplateId", "Priority" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ScheduleTemplates_ChannelId", name: "IX_ScheduleTemplates_ChannelId",
table: "ScheduleTemplates", table: "ScheduleTemplates",
column: "ChannelId" column: "ChannelId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Slots_GroupId", name: "IX_Slots_GroupId",
table: "Slots", table: "Slots",
column: "GroupId" column: "GroupId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Slots_LayerId_TargetStart", name: "IX_Slots_LayerId_TargetStart",
table: "Slots", table: "Slots",
columns: new[] { "LayerId", "TargetStart" } columns: new[] { "LayerId", "TargetStart" }
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "SlotStates"); migrationBuilder.DropTable(name: "SlotStates");
migrationBuilder.DropTable(name: "Slots"); migrationBuilder.DropTable(name: "Slots");
migrationBuilder.DropTable(name: "GridLayers"); migrationBuilder.DropTable(name: "GridLayers");
migrationBuilder.DropTable(name: "ScheduleTemplates"); migrationBuilder.DropTable(name: "ScheduleTemplates");
migrationBuilder.DropIndex(name: "IX_Channels_Number", table: "Channels"); migrationBuilder.DropIndex(name: "IX_Channels_Number", table: "Channels");
migrationBuilder.DropColumn(name: "DayStartTime", table: "Channels"); migrationBuilder.DropColumn(name: "DayStartTime", table: "Channels");
migrationBuilder.DropColumn(name: "Number", table: "Channels"); migrationBuilder.DropColumn(name: "Number", table: "Channels");
migrationBuilder.DropColumn(name: "TemplateId", table: "Channels"); migrationBuilder.DropColumn(name: "TemplateId", table: "Channels");
migrationBuilder.DropColumn(name: "UtcOffsetMinutes", table: "Channels"); migrationBuilder.DropColumn(name: "UtcOffsetMinutes", table: "Channels");
} }
} }
} }
@@ -1,59 +1,59 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ScheduleEntryTrace : Migration public partial class ScheduleEntryTrace : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId", name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries" table: "ScheduleEntries"
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "SlotId", name: "SlotId",
table: "ScheduleEntries", table: "ScheduleEntries",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "TraceJson", name: "TraceJson",
table: "ScheduleEntries", table: "ScheduleEntries",
type: "jsonb", type: "jsonb",
nullable: true nullable: true
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc", name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
table: "ScheduleEntries", table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId", "StartsAtUtc" } columns: new[] { "ChannelId", "ShowId", "StartsAtUtc" }
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropIndex( migrationBuilder.DropIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc", name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
table: "ScheduleEntries" table: "ScheduleEntries"
); );
migrationBuilder.DropColumn(name: "SlotId", table: "ScheduleEntries"); migrationBuilder.DropColumn(name: "SlotId", table: "ScheduleEntries");
migrationBuilder.DropColumn(name: "TraceJson", table: "ScheduleEntries"); migrationBuilder.DropColumn(name: "TraceJson", table: "ScheduleEntries");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId", name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries", table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId" } columns: new[] { "ChannelId", "ShowId" }
); );
} }
} }
} }
@@ -1,219 +1,219 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class DropLegacyRotation : Migration public partial class DropLegacyRotation : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "ChannelAd"); migrationBuilder.DropTable(name: "ChannelAd");
migrationBuilder.DropTable(name: "ChannelShowHour"); migrationBuilder.DropTable(name: "ChannelShowHour");
migrationBuilder.DropTable(name: "OverrideShow"); migrationBuilder.DropTable(name: "OverrideShow");
migrationBuilder.DropTable(name: "ChannelShow"); migrationBuilder.DropTable(name: "ChannelShow");
migrationBuilder.DropTable(name: "ProgrammingOverride"); migrationBuilder.DropTable(name: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "AdInsertion", table: "Channels"); migrationBuilder.DropColumn(name: "AdInsertion", table: "Channels");
migrationBuilder.DropColumn(name: "AdsPerBreak", table: "Channels"); migrationBuilder.DropColumn(name: "AdsPerBreak", table: "Channels");
migrationBuilder.DropColumn(name: "NextAdIndex", table: "Channels"); migrationBuilder.DropColumn(name: "NextAdIndex", table: "Channels");
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "AdInsertion", name: "AdInsertion",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "AdsPerBreak", name: "AdsPerBreak",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "NextAdIndex", name: "NextAdIndex",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ChannelAd", name: "ChannelAd",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false), MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ChannelAd", x => x.Id); table.PrimaryKey("PK_ChannelAd", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ChannelAd_Channels_ChannelId", name: "FK_ChannelAd_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ChannelShow", name: "ChannelShow",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
BlockMode = table.Column<int>(type: "integer", nullable: false), BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false), BlockValue = table.Column<int>(type: "integer", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false), IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false), NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
PreferredWeightMultiplier = table.Column<int>( PreferredWeightMultiplier = table.Column<int>(
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 3 defaultValue: 3
), ),
ShowId = table.Column<Guid>(type: "uuid", nullable: false), ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false), Weight = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ChannelShow", x => x.Id); table.PrimaryKey("PK_ChannelShow", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ChannelShow_Channels_ChannelId", name: "FK_ChannelShow_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ProgrammingOverride", name: "ProgrammingOverride",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
DayOfWeek = table.Column<int>(type: "integer", nullable: true), DayOfWeek = table.Column<int>(type: "integer", nullable: true),
EndMinute = table.Column<int>(type: "integer", nullable: true), EndMinute = table.Column<int>(type: "integer", nullable: true),
EndsAtUtc = table.Column<DateTimeOffset>( EndsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: true nullable: true
), ),
Mode = table.Column<int>(type: "integer", nullable: false), Mode = table.Column<int>(type: "integer", nullable: false),
Recurrence = table.Column<int>(type: "integer", nullable: false), Recurrence = table.Column<int>(type: "integer", nullable: false),
StartMinute = table.Column<int>(type: "integer", nullable: true), StartMinute = table.Column<int>(type: "integer", nullable: true),
StartsAtUtc = table.Column<DateTimeOffset>( StartsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: true nullable: true
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id); table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ProgrammingOverride_Channels_ChannelId", name: "FK_ProgrammingOverride_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ChannelShowHour", name: "ChannelShowHour",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false), ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
EndHour = table.Column<int>(type: "integer", nullable: false), EndHour = table.Column<int>(type: "integer", nullable: false),
StartHour = table.Column<int>(type: "integer", nullable: false), StartHour = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_ChannelShowHour", x => x.Id); table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId", name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
column: x => x.ChannelShowId, column: x => x.ChannelShowId,
principalTable: "ChannelShow", principalTable: "ChannelShow",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "OverrideShow", name: "OverrideShow",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false), ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false), ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false), Weight = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_OverrideShow", x => x.Id); table.PrimaryKey("PK_OverrideShow", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId", name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
column: x => x.ProgrammingOverrideId, column: x => x.ProgrammingOverrideId,
principalTable: "ProgrammingOverride", principalTable: "ProgrammingOverride",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ChannelAd_ChannelId_Position", name: "IX_ChannelAd_ChannelId_Position",
table: "ChannelAd", table: "ChannelAd",
columns: new[] { "ChannelId", "Position" } columns: new[] { "ChannelId", "Position" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ChannelShow_ChannelId_ShowId", name: "IX_ChannelShow_ChannelId_ShowId",
table: "ChannelShow", table: "ChannelShow",
columns: new[] { "ChannelId", "ShowId" } columns: new[] { "ChannelId", "ShowId" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ChannelShowHour_ChannelShowId", name: "IX_ChannelShowHour_ChannelShowId",
table: "ChannelShowHour", table: "ChannelShowHour",
column: "ChannelShowId" column: "ChannelShowId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_OverrideShow_ProgrammingOverrideId", name: "IX_OverrideShow_ProgrammingOverrideId",
table: "OverrideShow", table: "OverrideShow",
column: "ProgrammingOverrideId" column: "ProgrammingOverrideId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc", name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
table: "ProgrammingOverride", table: "ProgrammingOverride",
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" } columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
); );
} }
} }
} }
@@ -1,125 +1,125 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddJunctionTemplates : Migration public partial class AddJunctionTemplates : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "JunctionAfterId", name: "JunctionAfterId",
table: "Slots", table: "Slots",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "JunctionBetweenId", name: "JunctionBetweenId",
table: "Slots", table: "Slots",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "DefaultJunctionId", name: "DefaultJunctionId",
table: "ScheduleTemplates", table: "ScheduleTemplates",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "JunctionTemplates", name: "JunctionTemplates",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false), ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>( Name = table.Column<string>(
type: "character varying(128)", type: "character varying(128)",
maxLength: 128, maxLength: 128,
nullable: false nullable: false
), ),
CreatedAt = table.Column<DateTimeOffset>( CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone", type: "timestamp with time zone",
nullable: false nullable: false
), ),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_JunctionTemplates", x => x.Id); table.PrimaryKey("PK_JunctionTemplates", x => x.Id);
} }
); );
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "JunctionElements", name: "JunctionElements",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
JunctionTemplateId = table.Column<Guid>(type: "uuid", nullable: false), JunctionTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false), Position = table.Column<int>(type: "integer", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false), Kind = table.Column<int>(type: "integer", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: true), GroupId = table.Column<Guid>(type: "uuid", nullable: true),
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: true), BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: true),
AmountMode = table.Column<int>(type: "integer", nullable: false), AmountMode = table.Column<int>(type: "integer", nullable: false),
AmountValue = table.Column<int>(type: "integer", nullable: false), AmountValue = table.Column<int>(type: "integer", nullable: false),
IsRequired = table.Column<bool>(type: "boolean", nullable: false), IsRequired = table.Column<bool>(type: "boolean", nullable: false),
ConditionsJson = table.Column<string>(type: "jsonb", nullable: true), ConditionsJson = table.Column<string>(type: "jsonb", nullable: true),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_JunctionElements", x => x.Id); table.PrimaryKey("PK_JunctionElements", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_JunctionElements_Groups_GroupId", name: "FK_JunctionElements_Groups_GroupId",
column: x => x.GroupId, column: x => x.GroupId,
principalTable: "Groups", principalTable: "Groups",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Restrict onDelete: ReferentialAction.Restrict
); );
table.ForeignKey( table.ForeignKey(
name: "FK_JunctionElements_JunctionTemplates_JunctionTemplateId", name: "FK_JunctionElements_JunctionTemplates_JunctionTemplateId",
column: x => x.JunctionTemplateId, column: x => x.JunctionTemplateId,
principalTable: "JunctionTemplates", principalTable: "JunctionTemplates",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade onDelete: ReferentialAction.Cascade
); );
} }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_JunctionElements_GroupId", name: "IX_JunctionElements_GroupId",
table: "JunctionElements", table: "JunctionElements",
column: "GroupId" column: "GroupId"
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_JunctionElements_JunctionTemplateId_Position", name: "IX_JunctionElements_JunctionTemplateId_Position",
table: "JunctionElements", table: "JunctionElements",
columns: new[] { "JunctionTemplateId", "Position" } columns: new[] { "JunctionTemplateId", "Position" }
); );
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_JunctionTemplates_ChannelId", name: "IX_JunctionTemplates_ChannelId",
table: "JunctionTemplates", table: "JunctionTemplates",
column: "ChannelId" column: "ChannelId"
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(name: "JunctionElements"); migrationBuilder.DropTable(name: "JunctionElements");
migrationBuilder.DropTable(name: "JunctionTemplates"); migrationBuilder.DropTable(name: "JunctionTemplates");
migrationBuilder.DropColumn(name: "JunctionAfterId", table: "Slots"); migrationBuilder.DropColumn(name: "JunctionAfterId", table: "Slots");
migrationBuilder.DropColumn(name: "JunctionBetweenId", table: "Slots"); migrationBuilder.DropColumn(name: "JunctionBetweenId", table: "Slots");
migrationBuilder.DropColumn(name: "DefaultJunctionId", table: "ScheduleTemplates"); migrationBuilder.DropColumn(name: "DefaultJunctionId", table: "ScheduleTemplates");
} }
} }
} }
@@ -1,64 +1,64 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class DropDeadBumperSettings : Migration public partial class DropDeadBumperSettings : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
// Значение 0 (прежняя «ротация») из перечисления убрано: курсора ротации больше нет, // Значение 0 (прежняя «ротация») из перечисления убрано: курсора ротации больше нет,
// и выбор молча вырождался в случайный. Переводим такие каналы на выбор по весам. // и выбор молча вырождался в случайный. Переводим такие каналы на выбор по весам.
migrationBuilder.Sql( migrationBuilder.Sql(
"""UPDATE "Channels" SET "BumperSelection" = 3 WHERE "BumperSelection" = 0;""" """UPDATE "Channels" SET "BumperSelection" = 3 WHERE "BumperSelection" = 0;"""
); );
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels"); migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels"); migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels"); migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "NextBumperIndex", table: "Channels"); migrationBuilder.DropColumn(name: "NextBumperIndex", table: "Channels");
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<double>( migrationBuilder.AddColumn<double>(
name: "BumperEpisodeChangeChance", name: "BumperEpisodeChangeChance",
table: "Channels", table: "Channels",
type: "double precision", type: "double precision",
nullable: false, nullable: false,
defaultValue: 0.0 defaultValue: 0.0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "BumperMinIntervalMinutes", name: "BumperMinIntervalMinutes",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<double>( migrationBuilder.AddColumn<double>(
name: "BumperShowChangeChance", name: "BumperShowChangeChance",
table: "Channels", table: "Channels",
type: "double precision", type: "double precision",
nullable: false, nullable: false,
defaultValue: 0.0 defaultValue: 0.0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "NextBumperIndex", name: "NextBumperIndex",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
} }
} }
} }
@@ -1,27 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class TemplatePlanningRules : Migration public partial class TemplatePlanningRules : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "RulesJson", name: "RulesJson",
table: "ScheduleTemplates", table: "ScheduleTemplates",
type: "jsonb", type: "jsonb",
nullable: true nullable: true
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "RulesJson", table: "ScheduleTemplates"); migrationBuilder.DropColumn(name: "RulesJson", table: "ScheduleTemplates");
} }
} }
} }
@@ -1,68 +1,68 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ChannelViewerSettings : Migration public partial class ChannelViewerSettings : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<double>( migrationBuilder.AddColumn<double>(
name: "AnalogFilterStrength", name: "AnalogFilterStrength",
table: "Channels", table: "Channels",
type: "double precision", type: "double precision",
nullable: false, nullable: false,
defaultValue: 0.0 defaultValue: 0.0
); );
migrationBuilder.AddColumn<int>( migrationBuilder.AddColumn<int>(
name: "LogoCorner", name: "LogoCorner",
table: "Channels", table: "Channels",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0 defaultValue: 0
); );
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "LogoImageId", name: "LogoImageId",
table: "Channels", table: "Channels",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
migrationBuilder.AddColumn<double>( migrationBuilder.AddColumn<double>(
name: "LogoOpacity", name: "LogoOpacity",
table: "Channels", table: "Channels",
type: "double precision", type: "double precision",
nullable: false, nullable: false,
defaultValue: 0.0 defaultValue: 0.0
); );
migrationBuilder.AddColumn<bool>( migrationBuilder.AddColumn<bool>(
name: "ShowClock", name: "ShowClock",
table: "Channels", table: "Channels",
type: "boolean", type: "boolean",
nullable: false, nullable: false,
defaultValue: false defaultValue: false
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "AnalogFilterStrength", table: "Channels"); migrationBuilder.DropColumn(name: "AnalogFilterStrength", table: "Channels");
migrationBuilder.DropColumn(name: "LogoCorner", table: "Channels"); migrationBuilder.DropColumn(name: "LogoCorner", table: "Channels");
migrationBuilder.DropColumn(name: "LogoImageId", table: "Channels"); migrationBuilder.DropColumn(name: "LogoImageId", table: "Channels");
migrationBuilder.DropColumn(name: "LogoOpacity", table: "Channels"); migrationBuilder.DropColumn(name: "LogoOpacity", table: "Channels");
migrationBuilder.DropColumn(name: "ShowClock", table: "Channels"); migrationBuilder.DropColumn(name: "ShowClock", table: "Channels");
} }
} }
} }
@@ -1,28 +1,28 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ScheduleEntryCollection : Migration public partial class ScheduleEntryCollection : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AddColumn<Guid>( migrationBuilder.AddColumn<Guid>(
name: "CollectionId", name: "CollectionId",
table: "ScheduleEntries", table: "ScheduleEntries",
type: "uuid", type: "uuid",
nullable: true nullable: true
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn(name: "CollectionId", table: "ScheduleEntries"); migrationBuilder.DropColumn(name: "CollectionId", table: "ScheduleEntries");
} }
} }
} }
@@ -1,38 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace TeleWave.Infrastructure.Migrations namespace TeleWave.Infrastructure.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class ShowAudienceMpaa : Migration public partial class ShowAudienceMpaa : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<int>( migrationBuilder.AlterColumn<int>(
name: "Audience", name: "Audience",
table: "Shows", table: "Shows",
type: "integer", type: "integer",
nullable: true, nullable: true,
oldClrType: typeof(int), oldClrType: typeof(int),
oldType: "integer" oldType: "integer"
); );
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterColumn<int>( migrationBuilder.AlterColumn<int>(
name: "Audience", name: "Audience",
table: "Shows", table: "Shows",
type: "integer", type: "integer",
nullable: false, nullable: false,
defaultValue: 0, defaultValue: 0,
oldClrType: typeof(int), oldClrType: typeof(int),
oldType: "integer", oldType: "integer",
oldNullable: true oldNullable: true
); );
} }
} }
} }
@@ -1,83 +1,83 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Auth; using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images; using TeleWave.Domain.Images;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings; using TeleWave.Domain.Settings;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Infrastructure.Persistence; namespace TeleWave.Infrastructure.Persistence;
/// <summary> /// <summary>
/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена /// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена
/// (добавляются по мере реализации фич). /// (добавляются по мере реализации фич).
/// </summary> /// </summary>
public class AppDbContext(DbContextOptions<AppDbContext> options) public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser, AppRole, Guid>(options), : IdentityDbContext<AppUser, AppRole, Guid>(options),
IAppDbContext IAppDbContext
{ {
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>(); public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
public DbSet<Show> Shows => Set<Show>(); public DbSet<Show> Shows => Set<Show>();
public DbSet<Genre> Genres => Set<Genre>(); public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreAlias> GenreAliases => Set<GenreAlias>(); public DbSet<GenreAlias> GenreAliases => Set<GenreAlias>();
public DbSet<ShowGenre> ShowGenres => Set<ShowGenre>(); public DbSet<ShowGenre> ShowGenres => Set<ShowGenre>();
public DbSet<Collection> Collections => Set<Collection>(); public DbSet<Collection> Collections => Set<Collection>();
public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>(); public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>();
public DbSet<Group> Groups => Set<Group>(); public DbSet<Group> Groups => Set<Group>();
public DbSet<GroupItem> GroupItems => Set<GroupItem>(); public DbSet<GroupItem> GroupItems => Set<GroupItem>();
public DbSet<ScheduleTemplate> ScheduleTemplates => Set<ScheduleTemplate>(); public DbSet<ScheduleTemplate> ScheduleTemplates => Set<ScheduleTemplate>();
public DbSet<GridLayer> GridLayers => Set<GridLayer>(); public DbSet<GridLayer> GridLayers => Set<GridLayer>();
public DbSet<Slot> Slots => Set<Slot>(); public DbSet<Slot> Slots => Set<Slot>();
public DbSet<SlotState> SlotStates => Set<SlotState>(); public DbSet<SlotState> SlotStates => Set<SlotState>();
public DbSet<JunctionTemplate> JunctionTemplates => Set<JunctionTemplate>(); public DbSet<JunctionTemplate> JunctionTemplates => Set<JunctionTemplate>();
public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>(); public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>();
public DbSet<Channel> Channels => Set<Channel>(); public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>(); public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>(); public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>(); public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>(); public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<Image> Images => Set<Image>(); public DbSet<Image> Images => Set<Image>();
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) => public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
Database.BeginTransactionAsync(cancellationToken); Database.BeginTransactionAsync(cancellationToken);
public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken) public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken)
{ {
// pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции. // pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции.
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию, // Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
// корректности не нарушают. // корректности не нарушают.
var key = BitConverter.ToInt64(channelId.ToByteArray()); var key = BitConverter.ToInt64(channelId.ToByteArray());
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken); return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
} }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
base.OnModelCreating(builder); base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный // Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию // ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0». // отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
foreach (var entityType in builder.Model.GetEntityTypes()) foreach (var entityType in builder.Model.GetEntityTypes())
{ {
if ( if (
entityType.ClrType.Namespace?.StartsWith( entityType.ClrType.Namespace?.StartsWith(
"TeleWave.Domain", "TeleWave.Domain",
StringComparison.Ordinal StringComparison.Ordinal
) != true ) != true
) )
continue; continue;
var idProperty = entityType.FindProperty("Id"); var idProperty = entityType.FindProperty("Id");
if (idProperty is not null && idProperty.ClrType == typeof(Guid)) if (idProperty is not null && idProperty.ClrType == typeof(Guid))
idProperty.ValueGenerated = ValueGenerated.Never; idProperty.ValueGenerated = ValueGenerated.Never;
} }
} }
} }
@@ -1,61 +1,61 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
namespace TeleWave.Infrastructure.Persistence.Configurations; namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ChannelConfiguration : IEntityTypeConfiguration<Channel> public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
{ {
public void Configure(EntityTypeBuilder<Channel> builder) public void Configure(EntityTypeBuilder<Channel> builder)
{ {
builder.Property(x => x.Name).IsRequired().HasMaxLength(256); builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.Slug).IsRequired().HasMaxLength(128); builder.Property(x => x.Slug).IsRequired().HasMaxLength(128);
builder.HasIndex(x => x.Slug).IsUnique(); builder.HasIndex(x => x.Slug).IsUnique();
// Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно. // Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно.
builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL"); builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL");
builder builder
.HasMany(x => x.BumperTemplates) .HasMany(x => x.BumperTemplates)
.WithOne() .WithOne()
.HasForeignKey(t => t.ChannelId) .HasForeignKey(t => t.ChannelId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field); builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field);
} }
} }
public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate> public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate>
{ {
public void Configure(EntityTypeBuilder<BumperTemplate> builder) public void Configure(EntityTypeBuilder<BumperTemplate> builder)
{ {
builder.HasIndex(x => new { x.ChannelId, x.Position }); builder.HasIndex(x => new { x.ChannelId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64); builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32); builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32); builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32); builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32); builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.AudioExtension).HasMaxLength(16); builder.Property(x => x.AudioExtension).HasMaxLength(16);
builder builder
.HasMany(x => x.Variants) .HasMany(x => x.Variants)
.WithOne() .WithOne()
.HasForeignKey(v => v.BumperTemplateId) .HasForeignKey(v => v.BumperTemplateId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field); builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field);
} }
} }
public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTextVariant> public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTextVariant>
{ {
public void Configure(EntityTypeBuilder<BumperTextVariant> builder) public void Configure(EntityTypeBuilder<BumperTextVariant> builder)
{ {
builder.HasIndex(x => new { x.BumperTemplateId, x.Position }); builder.HasIndex(x => new { x.BumperTemplateId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64); builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64); builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64);
builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64); builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64);
builder.Property(x => x.Line1).IsRequired().HasMaxLength(120); builder.Property(x => x.Line1).IsRequired().HasMaxLength(120);
builder.Property(x => x.Line2).IsRequired().HasMaxLength(120); builder.Property(x => x.Line2).IsRequired().HasMaxLength(120);
builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight); builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight);
} }
} }
@@ -1,53 +1,53 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Settings; using TeleWave.Application.Settings;
using TeleWave.Domain.Settings; using TeleWave.Domain.Settings;
namespace TeleWave.Infrastructure.Settings; namespace TeleWave.Infrastructure.Settings;
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама — /// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary> /// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
{ {
public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) => public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) =>
dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken); dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken);
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken) public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
{ {
await UpsertAsync( await UpsertAsync(
SettingKeys.RegistrationEnabled, SettingKeys.RegistrationEnabled,
enabled ? "true" : "false", enabled ? "true" : "false",
cancellationToken cancellationToken
); );
} }
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) => public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken); dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
public Task SetPreferredAudioLanguagesAsync( public Task SetPreferredAudioLanguagesAsync(
string value, string value,
CancellationToken cancellationToken CancellationToken cancellationToken
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken); ) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
public Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken) => public Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken) =>
dbContext.GetBoolSettingAsync(SettingKeys.ChannelNumbersEnabled, false, cancellationToken); dbContext.GetBoolSettingAsync(SettingKeys.ChannelNumbersEnabled, false, cancellationToken);
public Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken) => public Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken) =>
UpsertAsync( UpsertAsync(
SettingKeys.ChannelNumbersEnabled, SettingKeys.ChannelNumbersEnabled,
enabled ? "true" : "false", enabled ? "true" : "false",
cancellationToken cancellationToken
); );
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken) private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
{ {
var existing = await dbContext.AppSettings.FirstOrDefaultAsync( var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
s => s.Key == key, s => s.Key == key,
cancellationToken cancellationToken
); );
if (existing is null) if (existing is null)
dbContext.AppSettings.Add(AppSetting.Create(key, value)); dbContext.AppSettings.Add(AppSetting.Create(key, value));
else else
existing.SetValue(value); existing.SetValue(value);
} }
} }
@@ -1,69 +1,69 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel; using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.UpdateChannelSettings; using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Tests.Support; using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using Xunit; using Xunit;
namespace TeleWave.Application.Tests.Broadcast; namespace TeleWave.Application.Tests.Broadcast;
public class ChannelHandlersTests public class ChannelHandlersTests
{ {
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact] [Fact]
public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug() public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug()
{ {
var fixture = new TestDb(); var fixture = new TestDb();
await using var db = fixture.New(); await using var db = fixture.New();
var ok = await new CreateChannelCommandHandler(db).Handle( var ok = await new CreateChannelCommandHandler(db).Handle(
new CreateChannelCommand("News", "news"), new CreateChannelCommand("News", "news"),
CancellationToken.None CancellationToken.None
); );
Assert.True(ok.IsSuccess); Assert.True(ok.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
var dup = await new CreateChannelCommandHandler(db).Handle( var dup = await new CreateChannelCommandHandler(db).Handle(
new CreateChannelCommand("Other", "news"), new CreateChannelCommand("Other", "news"),
CancellationToken.None CancellationToken.None
); );
Assert.False(dup.IsSuccess); Assert.False(dup.IsSuccess);
Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error); Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error);
} }
[Fact] [Fact]
public async Task UpdateChannelSettings_UpdatesBumperChances() public async Task UpdateChannelSettings_UpdatesBumperChances()
{ {
var fixture = new TestDb(); var fixture = new TestDb();
var channel = Channel.Create("c", "c", T0); var channel = Channel.Create("c", "c", T0);
await using (var seed = fixture.New()) await using (var seed = fixture.New())
{ {
seed.Channels.Add(channel); seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None); await seed.SaveChangesAsync(CancellationToken.None);
} }
await using var db = fixture.New(); await using var db = fixture.New();
var result = await new UpdateChannelSettingsCommandHandler(db).Handle( var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
new UpdateChannelSettingsCommand( new UpdateChannelSettingsCommand(
channel.Id, channel.Id,
"c", "c",
true, true,
true, true,
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom), new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
null null
), ),
CancellationToken.None CancellationToken.None
); );
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
await using var verify = fixture.New(); await using var verify = fixture.New();
var stored = await verify.Channels.FindAsync(channel.Id); var stored = await verify.Channels.FindAsync(channel.Id);
Assert.Equal(BumperFont.Sans, stored!.BumperFont); Assert.Equal(BumperFont.Sans, stored!.BumperFont);
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection); Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
} }
} }
@@ -1,201 +1,201 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NSubstitute; using NSubstitute;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.GetChannel; using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule; using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels; using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow; using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Tests.Support; using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using Xunit; using Xunit;
namespace TeleWave.Application.Tests.Broadcast; namespace TeleWave.Application.Tests.Broadcast;
public class QueryHandlersTests public class QueryHandlersTests
{ {
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) => private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
new(db, new GroupStatsService(db, new GroupElementResolver(db))); new(db, new GroupStatsService(db, new GroupElementResolver(db)));
[Fact] [Fact]
public async Task GetChannel_UnknownId_ReturnsNotFound() public async Task GetChannel_UnknownId_ReturnsNotFound()
{ {
var fixture = new TestDb(); var fixture = new TestDb();
await using var db = fixture.New(); await using var db = fixture.New();
var result = await new GetChannelQueryHandler(db).Handle( var result = await new GetChannelQueryHandler(db).Handle(
new GetChannelQuery(Guid.NewGuid()), new GetChannelQuery(Guid.NewGuid()),
CancellationToken.None CancellationToken.None
); );
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
} }
[Fact] [Fact]
public async Task ListChannels_ReturnsSummaries() public async Task ListChannels_ReturnsSummaries()
{ {
var fixture = new TestDb(); var fixture = new TestDb();
await using (var seed = fixture.New()) await using (var seed = fixture.New())
{ {
seed.Channels.Add(Channel.Create("A", "a", T0)); seed.Channels.Add(Channel.Create("A", "a", T0));
seed.Channels.Add(Channel.Create("B", "b", T0)); seed.Channels.Add(Channel.Create("B", "b", T0));
await seed.SaveChangesAsync(CancellationToken.None); await seed.SaveChangesAsync(CancellationToken.None);
} }
await using var db = fixture.New(); await using var db = fixture.New();
var result = await new ListChannelsQueryHandler(db).Handle( var result = await new ListChannelsQueryHandler(db).Handle(
new ListChannelsQuery(), new ListChannelsQuery(),
CancellationToken.None CancellationToken.None
); );
Assert.Equal(2, result.Count); Assert.Equal(2, result.Count);
} }
[Fact] [Fact]
public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel() public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel()
{ {
var fixture = new TestDb(); var fixture = new TestDb();
var channel = Channel.Create("c", "c", T0); var channel = Channel.Create("c", "c", T0);
var show = Show.Create("Show A", ShowKind.Series); var show = Show.Create("Show A", ShowKind.Series);
var variant = channel.BumperTemplates[0].Variants[0]; var variant = channel.BumperTemplates[0].Variants[0];
var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload); var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload);
var program = ScheduleEntry.Program( var program = ScheduleEntry.Program(
channel.Id, channel.Id,
asset.Id, asset.Id,
T0, T0,
T0.AddMinutes(20), T0.AddMinutes(20),
show.Id, show.Id,
0 0
); );
var bumper = ScheduleEntry.Bumper( var bumper = ScheduleEntry.Bumper(
channel.Id, channel.Id,
Guid.NewGuid(), Guid.NewGuid(),
T0.AddMinutes(20), T0.AddMinutes(20),
T0.AddMinutes(20).AddSeconds(8), T0.AddMinutes(20).AddSeconds(8),
show.Id, show.Id,
variant.Id variant.Id
); );
await using (var seed = fixture.New()) await using (var seed = fixture.New())
{ {
seed.Shows.Add(show); seed.Shows.Add(show);
seed.Channels.Add(channel); seed.Channels.Add(channel);
seed.MediaAssets.Add(asset); seed.MediaAssets.Add(asset);
seed.ScheduleEntries.Add(program); seed.ScheduleEntries.Add(program);
seed.ScheduleEntries.Add(bumper); seed.ScheduleEntries.Add(bumper);
await seed.SaveChangesAsync(CancellationToken.None); await seed.SaveChangesAsync(CancellationToken.None);
} }
await using var db = fixture.New(); await using var db = fixture.New();
var result = await new GetChannelScheduleQueryHandler(db).Handle( var result = await new GetChannelScheduleQueryHandler(db).Handle(
new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)), new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)),
CancellationToken.None CancellationToken.None
); );
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Count); Assert.Equal(2, result.Value.Count);
var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper); var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper);
Assert.NotNull(bumperDto.BumperName); Assert.NotNull(bumperDto.BumperName);
} }
[Fact] [Fact]
public async Task GetShow_ReturnsDtoOrNotFound() public async Task GetShow_ReturnsDtoOrNotFound()
{ {
var fixture = new TestDb(); var fixture = new TestDb();
var show = Show.Create("A", ShowKind.Series); var show = Show.Create("A", ShowKind.Series);
await using (var seed = fixture.New()) await using (var seed = fixture.New())
{ {
seed.Shows.Add(show); seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None); await seed.SaveChangesAsync(CancellationToken.None);
} }
await using var db = fixture.New(); await using var db = fixture.New();
var ok = await new GetShowQueryHandler(db).Handle( var ok = await new GetShowQueryHandler(db).Handle(
new GetShowQuery(show.Id), new GetShowQuery(show.Id),
CancellationToken.None CancellationToken.None
); );
Assert.True(ok.IsSuccess); Assert.True(ok.IsSuccess);
var missing = await new GetShowQueryHandler(db).Handle( var missing = await new GetShowQueryHandler(db).Handle(
new GetShowQuery(Guid.NewGuid()), new GetShowQuery(Guid.NewGuid()),
CancellationToken.None CancellationToken.None
); );
Assert.False(missing.IsSuccess); Assert.False(missing.IsSuccess);
} }
[Fact] [Fact]
public async Task DeleteShow_RemovesOrNotFound() public async Task DeleteShow_RemovesOrNotFound()
{ {
var fixture = new TestDb(); var fixture = new TestDb();
var show = Show.Create("A", ShowKind.Series); var show = Show.Create("A", ShowKind.Series);
await using (var seed = fixture.New()) await using (var seed = fixture.New())
{ {
seed.Shows.Add(show); seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None); await seed.SaveChangesAsync(CancellationToken.None);
} }
await using var db = fixture.New(); await using var db = fixture.New();
var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
new DeleteShowCommand(Guid.NewGuid()), new DeleteShowCommand(Guid.NewGuid()),
CancellationToken.None CancellationToken.None
); );
Assert.False(missing.IsSuccess); Assert.False(missing.IsSuccess);
var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
new DeleteShowCommand(show.Id), new DeleteShowCommand(show.Id),
CancellationToken.None CancellationToken.None
); );
Assert.True(ok.IsSuccess); Assert.True(ok.IsSuccess);
} }
[Fact] [Fact]
public async Task AddAndRemoveBumperTemplate_WorkThroughStorage() public async Task AddAndRemoveBumperTemplate_WorkThroughStorage()
{ {
var fixture = new TestDb(); var fixture = new TestDb();
var channel = Channel.Create("c", "c", T0); var channel = Channel.Create("c", "c", T0);
await using (var seed = fixture.New()) await using (var seed = fixture.New())
{ {
seed.Channels.Add(channel); seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None); await seed.SaveChangesAsync(CancellationToken.None);
} }
Guid templateId; Guid templateId;
await using (var db = fixture.New()) await using (var db = fixture.New())
{ {
var added = await new AddBumperTemplateCommandHandler(db).Handle( var added = await new AddBumperTemplateCommandHandler(db).Handle(
new AddBumperTemplateCommand(channel.Id, ""), new AddBumperTemplateCommand(channel.Id, ""),
CancellationToken.None CancellationToken.None
); );
Assert.True(added.IsSuccess); Assert.True(added.IsSuccess);
templateId = added.Value; templateId = added.Value;
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
} }
var storage = Substitute.For<IBumperTemplateStorage>(); var storage = Substitute.For<IBumperTemplateStorage>();
await using (var db = fixture.New()) await using (var db = fixture.New())
{ {
var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle( var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
new RemoveBumperTemplateCommand(channel.Id, templateId), new RemoveBumperTemplateCommand(channel.Id, templateId),
CancellationToken.None CancellationToken.None
); );
Assert.True(removed.IsSuccess); Assert.True(removed.IsSuccess);
} }
await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>()); await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>());
// дефолтный блок удалить нельзя // дефолтный блок удалить нельзя
await using (var db = fixture.New()) await using (var db = fixture.New())
{ {
var def = await db var def = await db
.Channels.Include(c => c.BumperTemplates) .Channels.Include(c => c.BumperTemplates)
.FirstAsync(c => c.Id == channel.Id); .FirstAsync(c => c.Id == channel.Id);
var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle( var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id), new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id),
CancellationToken.None CancellationToken.None
); );
Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error); Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error);
} }
} }
} }
@@ -1,150 +1,150 @@
using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
using Xunit; using Xunit;
namespace TeleWave.Application.Tests.Programming; namespace TeleWave.Application.Tests.Programming;
/// <summary> /// <summary>
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки. /// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
/// </summary> /// </summary>
public class LayerApplicabilityTests public class LayerApplicabilityTests
{ {
private static readonly TimeOnly DayStart = new(6, 0); private static readonly TimeOnly DayStart = new(6, 0);
[Fact] [Fact]
public void Empty_CoversEveryDate() public void Empty_CoversEveryDate()
{ {
var applicability = new LayerApplicability(); var applicability = new LayerApplicability();
Assert.True(applicability.IsEmpty); Assert.True(applicability.IsEmpty);
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17))); Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
} }
[Theory] [Theory]
[InlineData(2026, 12, 25, true)] // внутри, до Нового года [InlineData(2026, 12, 25, true)] // внутри, до Нового года
[InlineData(2026, 1, 3, true)] // внутри, после Нового года [InlineData(2026, 1, 3, true)] // внутри, после Нового года
[InlineData(2026, 12, 20, true)] // ровно начало [InlineData(2026, 12, 20, true)] // ровно начало
[InlineData(2026, 1, 8, true)] // ровно конец [InlineData(2026, 1, 8, true)] // ровно конец
[InlineData(2026, 12, 19, false)] // за день до начала [InlineData(2026, 12, 19, false)] // за день до начала
[InlineData(2026, 1, 9, false)] // на следующий день после конца [InlineData(2026, 1, 9, false)] // на следующий день после конца
[InlineData(2026, 6, 15, false)] // середина года [InlineData(2026, 6, 15, false)] // середина года
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds( public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
int year, int year,
int month, int month,
int day, int day,
bool expected bool expected
) )
{ {
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт // «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
// по паре (месяц, день), а не по датам. // по паре (месяц, день), а не по датам.
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]); var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]);
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day))); Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
} }
[Fact] [Fact]
public void AnnualRange_WithinOneYear_DoesNotWrap() public void AnnualRange_WithinOneYear_DoesNotWrap()
{ {
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]); var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4))); Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4))); Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
} }
[Fact] [Fact]
public void Sections_AreCombinedWithOr() public void Sections_AreCombinedWithOr()
{ {
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет. // Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
var applicability = new LayerApplicability( var applicability = new LayerApplicability(
Weekdays: [1], Weekdays: [1],
SpecificDates: [new DateOnly(2026, 3, 21)] SpecificDates: [new DateOnly(2026, 3, 21)]
); );
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
} }
[Fact] [Fact]
public void Build_SkipsSlotsCoveredByHigherPriorityLayer() public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
{ {
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы // Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
// половину настройки — своей группы и стратегии у половинки нет. // половину настройки — своей группы и стратегии у половинки нет.
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка"); var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var top = template.AddLayer("Прайм", 100); var top = template.AddLayer("Прайм", 100);
var bottom = template.AddLayer("Обычный", 50); var bottom = template.AddLayer("Обычный", 50);
AddSlot(top, "Кино", new TimeOnly(20, 0), 120); AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60); AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60); AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList(); var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
Assert.Contains("Кино", titles); Assert.Contains("Кино", titles);
Assert.Contains("Ночь", titles); Assert.Contains("Ночь", titles);
Assert.DoesNotContain("Сериал", titles); Assert.DoesNotContain("Сериал", titles);
} }
[Fact] [Fact]
public void Build_DropsLayersThatDoNotApplyOnTheDate() public void Build_DropsLayersThatDoNotApplyOnTheDate()
{ {
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка"); var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var newYear = template.AddLayer("Новогодний", 100); var newYear = template.AddLayer("Новогодний", 100);
newYear.Update( newYear.Update(
newYear.Name, newYear.Name,
newYear.Priority, newYear.Priority,
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(), new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
isEnabled: true isEnabled: true
); );
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180); AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
var usual = template.AddLayer("Обычный", 50); var usual = template.AddLayer("Обычный", 50);
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60); AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero)) var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title) .Select(s => s.Slot.Title)
.ToList(); .ToList();
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero)) var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title) .Select(s => s.Slot.Title)
.ToList(); .ToList();
Assert.Contains("Вечерний сериал", march); Assert.Contains("Вечерний сериал", march);
Assert.DoesNotContain("Ирония судьбы", march); Assert.DoesNotContain("Ирония судьбы", march);
Assert.Contains("Ирония судьбы", december); Assert.Contains("Ирония судьбы", december);
Assert.DoesNotContain("Вечерний сериал", december); Assert.DoesNotContain("Вечерний сериал", december);
} }
[Fact] [Fact]
public void Build_DisabledLayerIsIgnored() public void Build_DisabledLayerIsIgnored()
{ {
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка"); var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var layer = template.AddLayer("Выключённый", 100); var layer = template.AddLayer("Выключённый", 100);
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60); AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
layer.Update(layer.Name, layer.Priority, null, isEnabled: false); layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
Assert.Empty(Build(template)); Assert.Empty(Build(template));
} }
private static void AddSlot( private static void AddSlot(
GridLayer layer, GridLayer layer,
string title, string title,
TimeOnly start, TimeOnly start,
int durationMinutes int durationMinutes
) => layer.AddSlot(title, start, durationMinutes); ) => layer.AddSlot(title, start, durationMinutes);
private static IReadOnlyList<ScheduledSlot> Build( private static IReadOnlyList<ScheduledSlot> Build(
ScheduleTemplate template, ScheduleTemplate template,
DateTimeOffset? from = null DateTimeOffset? from = null
) )
{ {
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero); var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
return EffectiveGridBuilder.Build( return EffectiveGridBuilder.Build(
template, template,
utcOffsetMinutes: 180, utcOffsetMinutes: 180,
DayStart, DayStart,
start, start,
start.AddHours(18) start.AddHours(18)
); );
} }
} }
@@ -1,77 +1,77 @@
using TeleWave.Application.Admin.Users.CreateUser; using TeleWave.Application.Admin.Users.CreateUser;
using TeleWave.Application.Admin.Users.ResetPassword; using TeleWave.Application.Admin.Users.ResetPassword;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.UpdateChannelSettings; using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using Xunit; using Xunit;
namespace TeleWave.Application.Tests.Validators; namespace TeleWave.Application.Tests.Validators;
public class ValidatorTests public class ValidatorTests
{ {
[Fact] [Fact]
public void UpdateBumperTextVariant_ChecksLengthsAndWeight() public void UpdateBumperTextVariant_ChecksLengthsAndWeight()
{ {
var v = new UpdateBumperTextVariantCommandValidator(); var v = new UpdateBumperTextVariantCommandValidator();
var good = new UpdateBumperTextVariantCommand( var good = new UpdateBumperTextVariantCommand(
Guid.NewGuid(), Guid.NewGuid(),
Guid.NewGuid(), Guid.NewGuid(),
Guid.NewGuid(), Guid.NewGuid(),
"Name", "Name",
BumperTextKind.NowNext, BumperTextKind.NowNext,
"NOW", "NOW",
"NEXT", "NEXT",
"l1", "l1",
"l2", "l2",
BumperTrigger.Both, BumperTrigger.Both,
3 3
); );
Assert.True(v.Validate(good).IsValid); Assert.True(v.Validate(good).IsValid);
Assert.False(v.Validate(good with { Name = "" }).IsValid); Assert.False(v.Validate(good with { Name = "" }).IsValid);
Assert.False(v.Validate(good with { Weight = -1 }).IsValid); Assert.False(v.Validate(good with { Weight = -1 }).IsValid);
Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid); Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid);
} }
[Fact] [Fact]
public void UpdateChannelSettings_ChecksRanges() public void UpdateChannelSettings_ChecksRanges()
{ {
var v = new UpdateChannelSettingsCommandValidator(); var v = new UpdateChannelSettingsCommandValidator();
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom); var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
var good = new UpdateChannelSettingsCommand( var good = new UpdateChannelSettingsCommand(
Guid.NewGuid(), Guid.NewGuid(),
"Name", "Name",
true, true,
true, true,
bumper, bumper,
null null
); );
Assert.True(v.Validate(good).IsValid); Assert.True(v.Validate(good).IsValid);
Assert.False(v.Validate(good with { Name = "" }).IsValid); Assert.False(v.Validate(good with { Name = "" }).IsValid);
} }
[Fact] [Fact]
public void ResetUserPassword_RequiresMinLength() public void ResetUserPassword_RequiresMinLength()
{ {
var v = new ResetUserPasswordCommandValidator(); var v = new ResetUserPasswordCommandValidator();
Assert.True(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "Password1")).IsValid); Assert.True(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "Password1")).IsValid);
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "short")).IsValid); Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "short")).IsValid);
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid); Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid);
} }
[Fact] [Fact]
public void CreateUser_ChecksNameAndPassword() public void CreateUser_ChecksNameAndPassword()
{ {
var v = new CreateUserCommandValidator(); var v = new CreateUserCommandValidator();
Assert.True(v.Validate(new CreateUserCommand("bob", "Password1", Guid.NewGuid())).IsValid); Assert.True(v.Validate(new CreateUserCommand("bob", "Password1", Guid.NewGuid())).IsValid);
Assert.False(v.Validate(new CreateUserCommand("ab", "Password1", Guid.NewGuid())).IsValid); Assert.False(v.Validate(new CreateUserCommand("ab", "Password1", Guid.NewGuid())).IsValid);
Assert.False(v.Validate(new CreateUserCommand("bob", "short", Guid.NewGuid())).IsValid); Assert.False(v.Validate(new CreateUserCommand("bob", "short", Guid.NewGuid())).IsValid);
Assert.False(v.Validate(new CreateUserCommand("bob", "Password1", Guid.Empty)).IsValid); Assert.False(v.Validate(new CreateUserCommand("bob", "Password1", Guid.Empty)).IsValid);
} }
} }
@@ -1,145 +1,145 @@
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using Xunit; using Xunit;
namespace TeleWave.Domain.Tests.Broadcast; namespace TeleWave.Domain.Tests.Broadcast;
public class ChannelTests public class ChannelTests
{ {
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static Channel NewChannel() => Channel.Create("News", "news", Epoch); private static Channel NewChannel() => Channel.Create("News", "news", Epoch);
[Fact] [Fact]
public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate() public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate()
{ {
var channel = NewChannel(); var channel = NewChannel();
Assert.NotEqual(Guid.Empty, channel.Id); Assert.NotEqual(Guid.Empty, channel.Id);
Assert.Equal("News", channel.Name); Assert.Equal("News", channel.Name);
Assert.Equal("news", channel.Slug); Assert.Equal("news", channel.Slug);
Assert.True(channel.IsEnabled); Assert.True(channel.IsEnabled);
Assert.Equal(Epoch, channel.EpochUtc); Assert.Equal(Epoch, channel.EpochUtc);
Assert.False(channel.BumpersEnabled); Assert.False(channel.BumpersEnabled);
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection); Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes); Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime); Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
var template = Assert.Single(channel.BumperTemplates); var template = Assert.Single(channel.BumperTemplates);
Assert.True(template.IsDefault); Assert.True(template.IsDefault);
Assert.Equal(0, template.Position); Assert.Equal(0, template.Position);
Assert.Single(template.Variants); // дефолтный подблок Assert.Single(template.Variants); // дефолтный подблок
} }
[Fact] [Fact]
public void UpdateSettings_ChangesFields() public void UpdateSettings_ChangesFields()
{ {
var channel = NewChannel(); var channel = NewChannel();
var filler = Guid.NewGuid(); var filler = Guid.NewGuid();
channel.UpdateSettings("N2", false, true, filler); channel.UpdateSettings("N2", false, true, filler);
Assert.Equal("N2", channel.Name); Assert.Equal("N2", channel.Name);
Assert.False(channel.IsEnabled); Assert.False(channel.IsEnabled);
Assert.True(channel.BumpersEnabled); Assert.True(channel.BumpersEnabled);
Assert.Equal(filler, channel.FillerAssetId); Assert.Equal(filler, channel.FillerAssetId);
} }
[Fact] [Fact]
public void UpdateBumperSettings_ChangesFontAndSelection() public void UpdateBumperSettings_ChangesFontAndSelection()
{ {
var channel = NewChannel(); var channel = NewChannel();
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst); channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst);
Assert.Equal(BumperFont.Serif, channel.BumperFont); Assert.Equal(BumperFont.Serif, channel.BumperFont);
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection); Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
} }
[Theory] [Theory]
[InlineData(-1.0, 2.0, 0.0, 1.0)] [InlineData(-1.0, 2.0, 0.0, 1.0)]
[InlineData(0.4, 0.25, 0.4, 0.25)] [InlineData(0.4, 0.25, 0.4, 0.25)]
public void UpdateViewerSettings_ClampsStrengths( public void UpdateViewerSettings_ClampsStrengths(
double opacity, double opacity,
double filter, double filter,
double expectedOpacity, double expectedOpacity,
double expectedFilter double expectedFilter
) )
{ {
var channel = NewChannel(); var channel = NewChannel();
var logo = Guid.NewGuid(); var logo = Guid.NewGuid();
channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter); channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter);
Assert.Equal(logo, channel.LogoImageId); Assert.Equal(logo, channel.LogoImageId);
Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner); Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner);
Assert.Equal(expectedOpacity, channel.LogoOpacity); Assert.Equal(expectedOpacity, channel.LogoOpacity);
Assert.True(channel.ShowClock); Assert.True(channel.ShowClock);
Assert.Equal(expectedFilter, channel.AnalogFilterStrength); Assert.Equal(expectedFilter, channel.AnalogFilterStrength);
} }
[Fact] [Fact]
public void NewChannel_HasViewerOverlaysOff() public void NewChannel_HasViewerOverlaysOff()
{ {
// Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная. // Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная.
var channel = NewChannel(); var channel = NewChannel();
Assert.Null(channel.LogoImageId); Assert.Null(channel.LogoImageId);
Assert.False(channel.ShowClock); Assert.False(channel.ShowClock);
Assert.Equal(0.0, channel.AnalogFilterStrength); Assert.Equal(0.0, channel.AnalogFilterStrength);
} }
[Fact] [Fact]
public void AddBumperTemplate_AppendsWithNextPosition() public void AddBumperTemplate_AppendsWithNextPosition()
{ {
var channel = NewChannel(); var channel = NewChannel();
var t1 = channel.AddBumperTemplate("Block 2"); var t1 = channel.AddBumperTemplate("Block 2");
var t2 = channel.AddBumperTemplate("Block 3"); var t2 = channel.AddBumperTemplate("Block 3");
Assert.Equal(1, t1.Position); Assert.Equal(1, t1.Position);
Assert.Equal(2, t2.Position); Assert.Equal(2, t2.Position);
Assert.False(t1.IsDefault); Assert.False(t1.IsDefault);
Assert.Equal(3, channel.BumperTemplates.Count); Assert.Equal(3, channel.BumperTemplates.Count);
Assert.Equal(t1, channel.FindBumperTemplate(t1.Id)); Assert.Equal(t1, channel.FindBumperTemplate(t1.Id));
Assert.Null(channel.FindBumperTemplate(Guid.NewGuid())); Assert.Null(channel.FindBumperTemplate(Guid.NewGuid()));
} }
[Fact] [Fact]
public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers() public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers()
{ {
var channel = NewChannel(); var channel = NewChannel();
var def = channel.BumperTemplates[0]; var def = channel.BumperTemplates[0];
var extra = channel.AddBumperTemplate("Block 2"); var extra = channel.AddBumperTemplate("Block 2");
Assert.False(channel.RemoveBumperTemplate(def.Id)); Assert.False(channel.RemoveBumperTemplate(def.Id));
Assert.True(channel.RemoveBumperTemplate(extra.Id)); Assert.True(channel.RemoveBumperTemplate(extra.Id));
Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid())); Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid()));
Assert.Single(channel.BumperTemplates); Assert.Single(channel.BumperTemplates);
} }
[Fact] [Fact]
public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber() public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber()
{ {
var channel = NewChannel(); var channel = NewChannel();
channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0)); channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0));
// Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря. // Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря.
Assert.Null(channel.Number); Assert.Null(channel.Number);
Assert.Equal(14 * 60, channel.UtcOffsetMinutes); Assert.Equal(14 * 60, channel.UtcOffsetMinutes);
Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime); Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime);
} }
[Fact] [Fact]
public void SetTemplate_LinksAndUnlinks() public void SetTemplate_LinksAndUnlinks()
{ {
var channel = NewChannel(); var channel = NewChannel();
var templateId = Guid.NewGuid(); var templateId = Guid.NewGuid();
channel.SetTemplate(templateId); channel.SetTemplate(templateId);
Assert.Equal(templateId, channel.TemplateId); Assert.Equal(templateId, channel.TemplateId);
channel.SetTemplate(null); channel.SetTemplate(null);
Assert.Null(channel.TemplateId); Assert.Null(channel.TemplateId);
} }
} }
@@ -1,41 +1,41 @@
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Settings; using TeleWave.Domain.Settings;
using Xunit; using Xunit;
namespace TeleWave.Domain.Tests.Broadcast; namespace TeleWave.Domain.Tests.Broadcast;
/// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary> /// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary>
public class DomainRecordFactoryTests public class DomainRecordFactoryTests
{ {
[Fact] [Fact]
public void BumperAsset_Create_StoresPair() public void BumperAsset_Create_StoresPair()
{ {
var channel = Guid.NewGuid(); var channel = Guid.NewGuid();
var template = Guid.NewGuid(); var template = Guid.NewGuid();
var variant = Guid.NewGuid(); var variant = Guid.NewGuid();
var from = Guid.NewGuid(); var from = Guid.NewGuid();
var to = Guid.NewGuid(); var to = Guid.NewGuid();
var asset = Guid.NewGuid(); var asset = Guid.NewGuid();
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset); var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
Assert.Equal(channel, b.ChannelId); Assert.Equal(channel, b.ChannelId);
Assert.Equal(template, b.TemplateId); Assert.Equal(template, b.TemplateId);
Assert.Equal(variant, b.VariantId); Assert.Equal(variant, b.VariantId);
Assert.Equal(from, b.FromShowId); Assert.Equal(from, b.FromShowId);
Assert.Equal(to, b.ToShowId); Assert.Equal(to, b.ToShowId);
Assert.Equal("sig", b.Signature); Assert.Equal("sig", b.Signature);
Assert.Equal(asset, b.MediaAssetId); Assert.Equal(asset, b.MediaAssetId);
} }
[Fact] [Fact]
public void AppSetting_CreateAndSetValue() public void AppSetting_CreateAndSetValue()
{ {
var s = AppSetting.Create("site.title", "TeleWave"); var s = AppSetting.Create("site.title", "TeleWave");
Assert.Equal("site.title", s.Key); Assert.Equal("site.title", s.Key);
Assert.Equal("TeleWave", s.Value); Assert.Equal("TeleWave", s.Value);
s.SetValue("New"); s.SetValue("New");
Assert.Equal("New", s.Value); Assert.Equal("New", s.Value);
} }
} }
@@ -1,298 +1,298 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NSubstitute; using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence; using TeleWave.Infrastructure.Persistence;
using Xunit; using Xunit;
namespace TeleWave.Integration.Tests; namespace TeleWave.Integration.Tests;
/// <summary> /// <summary>
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты, /// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется /// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete. /// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
/// </summary> /// </summary>
[Collection("postgres")] [Collection("postgres")]
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture) public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{ {
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
[SkippableFact] [SkippableFact]
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor() public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3); var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default); var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
Assert.False(report.ChannelSkipped); Assert.False(report.ChannelSkipped);
Assert.True(report.Added > 0); Assert.True(report.Added > 0);
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
var entries = verify var entries = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId) .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
.OrderBy(e => e.StartsAtUtc) .OrderBy(e => e.StartsAtUtc)
.ToList(); .ToList();
Assert.NotEmpty(entries); Assert.NotEmpty(entries);
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру. // Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
for (var i = 1; i < entries.Count; i++) for (var i = 1; i < entries.Count; i++)
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc); Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList(); var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs); Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId)); Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
Assert.All(programs, e => Assert.NotNull(e.TraceJson)); Assert.All(programs, e => Assert.NotNull(e.TraceJson));
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой. // Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId); var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
Assert.Equal(world.ShowId, state.CurrentElementId); Assert.Equal(world.ShowId, state.CurrentElementId);
Assert.True(state.NextUnitIndex > 0); Assert.True(state.NextUnitIndex > 0);
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть. // И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
Assert.False( Assert.False(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
); );
} }
[SkippableFact] [SkippableFact]
public async Task Generate_IsIdempotentWithinHorizon() public async Task Generate_IsIdempotentWithinHorizon()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3); var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext(); await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default); await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var second = fixture.CreateContext(); await using var second = fixture.CreateContext();
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default); var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего. // Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
Assert.Equal(0, again.Added); Assert.Equal(0, again.Added);
} }
[SkippableFact] [SkippableFact]
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture() public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3); var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext(); await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default); await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext(); await using var beforeDb = fixture.CreateContext();
var past = beforeDb var past = beforeDb
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now) .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
.Select(e => e.Id) .Select(e => e.Id)
.ToHashSet(); .ToHashSet();
Assert.NotEmpty(past); Assert.NotEmpty(past);
await using var rebuild = fixture.CreateContext(); await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default); await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
var survived = verify var survived = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id)) .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
.Select(e => e.Id) .Select(e => e.Id)
.ToList(); .ToList();
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу. // Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
Assert.Equal(past.Count, survived.Count); Assert.Equal(past.Count, survived.Count);
} }
[SkippableFact] [SkippableFact]
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries() public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true); var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default); await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
var programs = verify var programs = verify
.ScheduleEntries.Where(e => .ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
) )
.ToList(); .ToList();
Assert.NotEmpty(programs); Assert.NotEmpty(programs);
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте. // Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId)); Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
} }
[SkippableFact] [SkippableFact]
public async Task Preview_WritesNothing() public async Task Preview_WritesNothing()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3); var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default); var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
Assert.NotNull(result); Assert.NotNull(result);
Assert.NotEmpty(result.Items); Assert.NotEmpty(result.Items);
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении. // Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)); Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId)); Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что. // Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
Assert.True( Assert.True(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
); );
} }
[SkippableFact] [SkippableFact]
public async Task Generate_EmptyGroup_FallsBackAndWarns() public async Task Generate_EmptyGroup_FallsBackAndWarns()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 0); var world = await SeedAsync(seedDb, episodes: 0);
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default); var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение. // Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
Assert.Contains( Assert.Contains(
report.Warnings, report.Warnings,
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
); );
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
Assert.All( Assert.All(
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(), verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind) e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
); );
} }
// ── Обвязка ───────────────────────────────────────────────────────────── // ── Обвязка ─────────────────────────────────────────────────────────────
private static GridScheduleGenerator Generator(AppDbContext db) private static GridScheduleGenerator Generator(AppDbContext db)
{ {
var random = new SequenceRandom(0); var random = new SequenceRandom(0);
return new GridScheduleGenerator( return new GridScheduleGenerator(
db, db,
new GroupExpander(db), new GroupExpander(db),
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random), new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
new PostCheckRunner(db), new PostCheckRunner(db),
random, random,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }), Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions()) Options.Create(new StreamingOptions())
); );
} }
private sealed record World( private sealed record World(
Guid ChannelId, Guid ChannelId,
Guid TemplateId, Guid TemplateId,
Guid SlotId, Guid SlotId,
Guid ShowId, Guid ShowId,
Guid? CollectionId Guid? CollectionId
); );
/// <summary> /// <summary>
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день /// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу. /// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
/// </summary> /// </summary>
private static async Task<World> SeedAsync( private static async Task<World> SeedAsync(
AppDbContext db, AppDbContext db,
int episodes, int episodes,
bool asCollection = false bool asCollection = false
) )
{ {
var suffix = Guid.NewGuid().ToString("N")[..8]; var suffix = Guid.NewGuid().ToString("N")[..8];
var show = Show.Create($"Шоу {suffix}", ShowKind.Series); var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
for (var i = 0; i < episodes; i++) for (var i = 0; i < episodes; i++)
{ {
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30)); var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
show.AddEpisode(asset.Id); show.AddEpisode(asset.Id);
} }
db.Shows.Add(show); db.Shows.Add(show);
Collection? collection = null; Collection? collection = null;
if (asCollection) if (asCollection)
{ {
collection = Collection.Create($"Франшиза {suffix}"); collection = Collection.Create($"Франшиза {suffix}");
collection.AddShow(show.Id); collection.AddShow(show.Id);
db.Collections.Add(collection); db.Collections.Add(collection);
} }
var group = Group.Create($"Группа {suffix}"); var group = Group.Create($"Группа {suffix}");
group.AddElement( group.AddElement(
asCollection ? GroupElementKind.Collection : GroupElementKind.Show, asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
asCollection ? collection!.Id : show.Id asCollection ? collection!.Id : show.Id
); );
db.Groups.Add(group); db.Groups.Add(group);
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1)); var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7)); var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id); channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка"); var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10); var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60); var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent( slot.UpdateContent(
new SlotContent( new SlotContent(
slot.Title, slot.Title,
SlotKind.Content, SlotKind.Content,
group.Id, group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(), new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null, null,
SlotBlockMode.FillSlot, SlotBlockMode.FillSlot,
1, 1,
OverflowPolicy.ContinueNext OverflowPolicy.ContinueNext
) )
); );
channel.SetTemplate(template.Id); channel.SetTemplate(template.Id);
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить». // Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
template.MarkChanged(); template.MarkChanged();
db.Channels.Add(channel); db.Channels.Add(channel);
db.ScheduleTemplates.Add(template); db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id); return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
} }
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary> /// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration) private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{ {
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload); var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
asset.MarkProcessing(); asset.MarkProcessing();
asset.MarkReady( asset.MarkReady(
new MediaReadyInfo( new MediaReadyInfo(
duration, duration,
SegmentSeconds: 2, SegmentSeconds: 2,
SegmentCount: (int)(duration.TotalSeconds / 2), SegmentCount: (int)(duration.TotalSeconds / 2),
Width: 1920, Width: 1920,
Height: 1080, Height: 1080,
VideoCodec: "h264", VideoCodec: "h264",
AudioCodec: "aac", AudioCodec: "aac",
RelativePath: $"segments/{asset.Id}" RelativePath: $"segments/{asset.Id}"
) )
); );
db.MediaAssets.Add(asset); db.MediaAssets.Add(asset);
return asset; return asset;
} }
} }
@@ -1,176 +1,176 @@
using NSubstitute; using NSubstitute;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media.ManualInbox; using TeleWave.Application.Media.ManualInbox;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Persistence; using TeleWave.Infrastructure.Persistence;
using Xunit; using Xunit;
namespace TeleWave.Integration.Tests; namespace TeleWave.Integration.Tests;
/// <summary> /// <summary>
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против /// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске». /// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
/// </summary> /// </summary>
[Collection("postgres")] [Collection("postgres")]
public sealed class ManualInboxIntegrationTests(PostgresFixture fixture) public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
{ {
[SkippableFact] [SkippableFact]
public async Task Import_AddsEpisodesToShow_AndConsumesFiles() public async Task Import_AddsEpisodesToShow_AndConsumesFiles()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb); var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8]; var tag = Guid.NewGuid().ToString("N")[..8];
var first = $"{tag}-s01e01.mkv"; var first = $"{tag}-s01e01.mkv";
var second = $"{tag}-s01e02.mkv"; var second = $"{tag}-s01e02.mkv";
var storage = Substitute.For<IMediaStorage>(); var storage = Substitute.For<IMediaStorage>();
storage storage
.ListManualInbox(Arg.Any<int>()) .ListManualInbox(Arg.Any<int>())
.Returns([ .Returns([
new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000), new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000),
new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000), new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000),
]); ]);
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler( var result = await new ImportManualInboxCommandHandler(
db, db,
storage, storage,
Substitute.For<IMediaProcessingQueue>() Substitute.For<IMediaProcessingQueue>()
).Handle( ).Handle(
new ImportManualInboxCommand( new ImportManualInboxCommand(
[Item($"Сериал/{first}"), Item($"Сериал/{second}")], [Item($"Сериал/{first}"), Item($"Сериал/{second}")],
showId showId
), ),
default default
); );
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Imported); Assert.Equal(2, result.Value.Imported);
Assert.Empty(result.Value.Failed); Assert.Empty(result.Value.Failed);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Файлы должны быть перенесены из manual/ — ровно как из обычного inbox. // Файлы должны быть перенесены из manual/ — ровно как из обычного inbox.
await storage await storage
.Received(2) .Received(2)
.PromoteToOriginalAsync( .PromoteToOriginalAsync(
MediaSource.ManualInbox, MediaSource.ManualInbox,
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<Guid>(), Arg.Any<Guid>(),
".mkv", ".mkv",
Arg.Any<CancellationToken>() Arg.Any<CancellationToken>()
); );
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
var episodes = verify var episodes = verify
.Shows.Where(s => s.Id == showId) .Shows.Where(s => s.Id == showId)
.SelectMany(s => s.Episodes) .SelectMany(s => s.Episodes)
.OrderBy(e => e.Position) .OrderBy(e => e.Position)
.ToList(); .ToList();
Assert.Equal(2, episodes.Count); Assert.Equal(2, episodes.Count);
// Номера сезона и серии разбираются из имени файла, как при обычном добавлении. // Номера сезона и серии разбираются из имени файла, как при обычном добавлении.
Assert.Equal(1, episodes[0].Season); Assert.Equal(1, episodes[0].Season);
Assert.Equal(1, episodes[0].Episode); Assert.Equal(1, episodes[0].Episode);
Assert.Equal(2, episodes[1].Episode); Assert.Equal(2, episodes[1].Episode);
// Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже // Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже
// заводит записи из ручного inbox. // заводит записи из ручного inbox.
var assetIds = episodes.Select(e => e.MediaAssetId).ToList(); var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList(); var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList();
Assert.Equal(2, assets.Count); Assert.Equal(2, assets.Count);
Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source)); Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source));
Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status)); Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status));
} }
[SkippableFact] [SkippableFact]
public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch() public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb); var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8]; var tag = Guid.NewGuid().ToString("N")[..8];
var good = $"{tag}-ok.mkv"; var good = $"{tag}-ok.mkv";
var storage = Substitute.For<IMediaStorage>(); var storage = Substitute.For<IMediaStorage>();
storage storage
.ListManualInbox(Arg.Any<int>()) .ListManualInbox(Arg.Any<int>())
.Returns([ .Returns([
new IMediaStorage.ManualInboxFile(good, good, 1000), new IMediaStorage.ManualInboxFile(good, good, 1000),
new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10), new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10),
]); ]);
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler( var result = await new ImportManualInboxCommandHandler(
db, db,
storage, storage,
Substitute.For<IMediaProcessingQueue>() Substitute.For<IMediaProcessingQueue>()
).Handle( ).Handle(
new ImportManualInboxCommand( new ImportManualInboxCommand(
[Item(good), Item("readme.txt"), Item("ушёл.mkv")], [Item(good), Item("readme.txt"), Item("ушёл.mkv")],
showId showId
), ),
default default
); );
// Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет. // Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет.
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.Imported); Assert.Equal(1, result.Value.Imported);
Assert.Equal(2, result.Value.Failed.Count); Assert.Equal(2, result.Value.Failed.Count);
Assert.Contains(result.Value.Failed, f => f.RelativePath == "readme.txt"); Assert.Contains(result.Value.Failed, f => f.RelativePath == "readme.txt");
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv"); Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
} }
[SkippableFact] [SkippableFact]
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName() public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb); var showId = await SeedShowAsync(seedDb);
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv"; var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
var storage = Substitute.For<IMediaStorage>(); var storage = Substitute.For<IMediaStorage>();
storage storage
.ListManualInbox(Arg.Any<int>()) .ListManualInbox(Arg.Any<int>())
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]); .Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler( var result = await new ImportManualInboxCommandHandler(
db, db,
storage, storage,
Substitute.For<IMediaProcessingQueue>() Substitute.For<IMediaProcessingQueue>()
).Handle( ).Handle(
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId), new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
default default
); );
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single(); var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь. // Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
Assert.Equal(4, episode.Season); Assert.Equal(4, episode.Season);
Assert.Equal(12, episode.Episode); Assert.Equal(12, episode.Episode);
// И спутники рядом с забранным файлом убираются. // И спутники рядом с забранным файлом убираются.
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>()); await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
} }
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary> /// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null); private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
private static async Task<Guid> SeedShowAsync(AppDbContext db) private static async Task<Guid> SeedShowAsync(AppDbContext db)
{ {
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series); var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
db.Shows.Add(show); db.Shows.Add(show);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return show.Id; return show.Id;
} }
} }
@@ -1,70 +1,70 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Infrastructure.Persistence; using TeleWave.Infrastructure.Persistence;
using Testcontainers.PostgreSql; using Testcontainers.PostgreSql;
using Xunit; using Xunit;
namespace TeleWave.Integration.Tests; namespace TeleWave.Integration.Tests;
/// <summary> /// <summary>
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие /// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory /// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker. /// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
/// </summary> /// </summary>
public sealed class PostgresFixture : IAsyncLifetime public sealed class PostgresFixture : IAsyncLifetime
{ {
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен, // Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска. // и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
private PostgreSqlContainer? _container; private PostgreSqlContainer? _container;
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary> /// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
public bool Available { get; private set; } public bool Available { get; private set; }
public string ConnectionString => public string ConnectionString =>
_container?.GetConnectionString() _container?.GetConnectionString()
?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available."); ?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available.");
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
try try
{ {
_container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build(); _container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
await _container.StartAsync(); await _container.StartAsync();
await using var db = CreateContext(); await using var db = CreateContext();
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
Available = true; Available = true;
} }
catch (Exception) catch (Exception)
{ {
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут. // Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
Available = false; Available = false;
} }
} }
public AppDbContext CreateContext() => public AppDbContext CreateContext() =>
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options); new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
public async Task DisposeAsync() public async Task DisposeAsync()
{ {
if (_container is not null) if (_container is not null)
await _container.DisposeAsync(); await _container.DisposeAsync();
} }
} }
[CollectionDefinition("postgres")] [CollectionDefinition("postgres")]
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>; public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary> /// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
internal sealed class SequenceRandom(params int[] sequence) internal sealed class SequenceRandom(params int[] sequence)
: Domain.Broadcast.Scheduling.IRandomSource : Domain.Broadcast.Scheduling.IRandomSource
{ {
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence; private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
private int _i; private int _i;
public int Next(int maxExclusive) public int Next(int maxExclusive)
{ {
if (maxExclusive <= 0) if (maxExclusive <= 0)
return 0; return 0;
var value = _sequence[_i++ % _sequence.Length]; var value = _sequence[_i++ % _sequence.Length];
return ((value % maxExclusive) + maxExclusive) % maxExclusive; return ((value % maxExclusive) + maxExclusive) % maxExclusive;
} }
} }
@@ -1,183 +1,183 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate; using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateTemplate; using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.Validate; using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming; using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence; using TeleWave.Infrastructure.Persistence;
using Xunit; using Xunit;
namespace TeleWave.Integration.Tests; namespace TeleWave.Integration.Tests;
/// <summary> /// <summary>
/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам. /// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам.
/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать. /// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать.
/// </summary> /// </summary>
[Collection("postgres")] [Collection("postgres")]
public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
{ {
[SkippableFact] [SkippableFact]
public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid() public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb); var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
var result = await new CopyTemplateCommandHandler(db).Handle( var result = await new CopyTemplateCommandHandler(db).Handle(
new CopyTemplateCommand(sourceId, targetId), new CopyTemplateCommand(sourceId, targetId),
default default
); );
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
Assert.Equal(1, result.Value.Layers); Assert.Equal(1, result.Value.Layers);
Assert.Equal(1, result.Value.Slots); Assert.Equal(1, result.Value.Slots);
Assert.Equal(1, result.Value.Junctions); Assert.Equal(1, result.Value.Junctions);
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
var target = verify.Channels.Single(c => c.Id == targetId); var target = verify.Channels.Single(c => c.Id == targetId);
var copied = verify var copied = verify
.ScheduleTemplates.Include(t => t.Layers) .ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots) .ThenInclude(l => l.Slots)
.Single(t => t.ChannelId == targetId); .Single(t => t.ChannelId == targetId);
// У приёмника ровно один шаблон, и канал смотрит именно на него. // У приёмника ровно один шаблон, и канал смотрит именно на него.
Assert.Equal(copied.Id, target.TemplateId); Assert.Equal(copied.Id, target.TemplateId);
Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId)); Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId));
var slot = copied.Layers.SelectMany(l => l.Slots).Single(); var slot = copied.Layers.SelectMany(l => l.Slots).Single();
// Группы общие — ссылка переносится как есть, а не копией группы. // Группы общие — ссылка переносится как есть, а не копией группы.
Assert.Equal(groupId, slot.GroupId); Assert.Equal(groupId, slot.GroupId);
Assert.Single(verify.Groups.Where(g => g.Id == groupId)); Assert.Single(verify.Groups.Where(g => g.Id == groupId));
// Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала. // Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала.
var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId); var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
Assert.Equal(junction.Id, slot.JunctionAfterId); Assert.Equal(junction.Id, slot.JunctionAfterId);
Assert.NotEqual( Assert.NotEqual(
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id, verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
slot.JunctionAfterId slot.JunctionAfterId
); );
} }
[Fact] [Fact]
public void Copy_ToItself_IsRejectedByValidator() public void Copy_ToItself_IsRejectedByValidator()
{ {
var validator = new CopyTemplateCommandValidator(); var validator = new CopyTemplateCommandValidator();
var id = Guid.NewGuid(); var id = Guid.NewGuid();
Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid); Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid);
Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid); Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid);
} }
[SkippableFact] [SkippableFact]
public async Task Validate_ReportsEmptyGroupAndGridGap() public async Task Validate_ReportsEmptyGroupAndGridGap()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var (sourceId, _, _) = await SeedPairAsync(seedDb); var (sourceId, _, _) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
var result = await new ValidateTemplateQueryHandler(db).Handle( var result = await new ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(sourceId), new ValidateTemplateQuery(sourceId),
default default
); );
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать. // Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty); Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty);
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap); Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap);
} }
[SkippableFact] [SkippableFact]
public async Task CreateTemplate_ForChannelWithoutGrid_LinksItAndIsIdempotent() public async Task CreateTemplate_ForChannelWithoutGrid_LinksItAndIsIdempotent()
{ {
Skip.IfNot(fixture.Available, "Docker недоступен"); Skip.IfNot(fixture.Available, "Docker недоступен");
// Канал из старой ротации: сетки нет, ссылки на неё тоже. // Канал из старой ротации: сетки нет, ссылки на неё тоже.
await using var seedDb = fixture.CreateContext(); await using var seedDb = fixture.CreateContext();
var suffix = Guid.NewGuid().ToString("N")[..8]; var suffix = Guid.NewGuid().ToString("N")[..8];
var channel = Channel.Create( var channel = Channel.Create(
$"Без сетки {suffix}", $"Без сетки {suffix}",
$"nogrid-{suffix}", $"nogrid-{suffix}",
DateTimeOffset.UtcNow DateTimeOffset.UtcNow
); );
seedDb.Channels.Add(channel); seedDb.Channels.Add(channel);
await seedDb.SaveChangesAsync(); await seedDb.SaveChangesAsync();
await using var db = fixture.CreateContext(); await using var db = fixture.CreateContext();
var created = await new CreateChannelTemplateCommandHandler(db).Handle( var created = await new CreateChannelTemplateCommandHandler(db).Handle(
new CreateChannelTemplateCommand(channel.Id), new CreateChannelTemplateCommand(channel.Id),
default default
); );
Assert.True(created.IsSuccess); Assert.True(created.IsSuccess);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await using var again = fixture.CreateContext(); await using var again = fixture.CreateContext();
var second = await new CreateChannelTemplateCommandHandler(again).Handle( var second = await new CreateChannelTemplateCommandHandler(again).Handle(
new CreateChannelTemplateCommand(channel.Id), new CreateChannelTemplateCommand(channel.Id),
default default
); );
await again.SaveChangesAsync(); await again.SaveChangesAsync();
// Повторный вызов возвращает ту же сетку: одна на канал, второй не появляется. // Повторный вызов возвращает ту же сетку: одна на канал, второй не появляется.
Assert.True(second.IsSuccess); Assert.True(second.IsSuccess);
Assert.Equal(created.Value, second.Value); Assert.Equal(created.Value, second.Value);
await using var verify = fixture.CreateContext(); await using var verify = fixture.CreateContext();
var template = verify.ScheduleTemplates.Single(t => t.ChannelId == channel.Id); var template = verify.ScheduleTemplates.Single(t => t.ChannelId == channel.Id);
Assert.Equal(template.Id, verify.Channels.Single(c => c.Id == channel.Id).TemplateId); Assert.Equal(template.Id, verify.Channels.Single(c => c.Id == channel.Id).TemplateId);
// Фоновый слой заводится сразу — без него первую же дыру в сетке нечем закрыть. // Фоновый слой заводится сразу — без него первую же дыру в сетке нечем закрыть.
Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground)); Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground));
} }
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary> /// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync( private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(
AppDbContext db AppDbContext db
) )
{ {
var suffix = Guid.NewGuid().ToString("N")[..8]; var suffix = Guid.NewGuid().ToString("N")[..8];
var group = Group.Create($"Группа {suffix}"); var group = Group.Create($"Группа {suffix}");
db.Groups.Add(group); db.Groups.Add(group);
var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow); var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow);
var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow); var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow);
var junction = JunctionTemplate.Create(source.Id, "Прайм"); var junction = JunctionTemplate.Create(source.Id, "Прайм");
var ad = junction.AddElement(JunctionElementKind.Ad); var ad = junction.AddElement(JunctionElementKind.Ad);
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null); ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null);
db.JunctionTemplates.Add(junction); db.JunctionTemplates.Add(junction);
var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника"); var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника");
var layer = sourceTemplate.AddLayer("Прайм", 10); var layer = sourceTemplate.AddLayer("Прайм", 10);
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120); var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
slot.UpdateContent( slot.UpdateContent(
new SlotContent( new SlotContent(
slot.Title, slot.Title,
SlotKind.Content, SlotKind.Content,
group.Id, group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(), new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null, null,
SlotBlockMode.FillSlot, SlotBlockMode.FillSlot,
1, 1,
OverflowPolicy.ContinueNext, OverflowPolicy.ContinueNext,
JunctionAfterId: junction.Id JunctionAfterId: junction.Id
) )
); );
sourceTemplate.SetDefaultJunction(junction.Id); sourceTemplate.SetDefaultJunction(junction.Id);
source.SetTemplate(sourceTemplate.Id); source.SetTemplate(sourceTemplate.Id);
var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника"); var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника");
target.SetTemplate(targetTemplate.Id); target.SetTemplate(targetTemplate.Id);
db.Channels.AddRange(source, target); db.Channels.AddRange(source, target);
db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate); db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return (source.Id, target.Id, group.Id); return (source.Id, target.Id, group.Id);
} }
} }
@@ -1,85 +1,85 @@
import { HelpCircle } from 'lucide-react' import { HelpCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { ScheduleEntryDto } from '@/shared/api/types' import type { ScheduleEntryDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { formatTime } from '../lib/format' import { formatTime } from '../lib/format'
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */ /** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) { function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
const { t } = useTranslation() const { t } = useTranslation()
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge> if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
if (entry.kind === 'Bumper') if (entry.kind === 'Bumper')
return ( return (
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0"> <Badge variant="muted" className="shrink-0">
{t('air.bumper')} {t('air.bumper')}
</Badge> </Badge>
{(entry.bumperName || entry.bumperText) && ( {(entry.bumperName || entry.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground"> <span className="min-w-0 truncate text-muted-foreground">
{entry.bumperName} {entry.bumperName}
{entry.bumperName && entry.bumperText ? ' · ' : ''} {entry.bumperName && entry.bumperText ? ' · ' : ''}
{entry.bumperText} {entry.bumperText}
</span> </span>
)} )}
</span> </span>
) )
return ( return (
<span> <span>
{entry.showName ?? '—'} {entry.showName ?? '—'}
<EpisodeSuffix entry={entry} /> <EpisodeSuffix entry={entry} />
</span> </span>
) )
} }
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */ /** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) { function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
const { t } = useTranslation() const { t } = useTranslation()
if (entry.seasonEpisode) if (entry.seasonEpisode)
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span> return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
if (entry.episodeIndex == null) return null if (entry.episodeIndex == null) return null
return ( return (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{' '} {' '}
· {t('air.episode')} {entry.episodeIndex + 1} · {t('air.episode')} {entry.episodeIndex + 1}
</span> </span>
) )
} }
export function SchedulePreview({ export function SchedulePreview({
entries, entries,
onShowTrace, onShowTrace,
}: Readonly<{ }: Readonly<{
entries: ScheduleEntryDto[] entries: ScheduleEntryDto[]
onShowTrace: (entryId: string) => void onShowTrace: (entryId: string) => void
}>) { }>) {
const { t } = useTranslation() const { t } = useTranslation()
if (entries.length === 0) if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p> return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return ( return (
<ul className="flex flex-col divide-y divide-border text-sm"> <ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => ( {entries.slice(0, 40).map((e) => (
<li key={e.id} className="group flex items-center gap-3 py-1.5"> <li key={e.id} className="group flex items-center gap-3 py-1.5">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground"> <span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(e.startsAtUtc)} {formatTime(e.startsAtUtc)}
</span> </span>
<EntryLabel entry={e} /> <EntryLabel entry={e} />
<button <button
type="button" type="button"
title={t('admin.channels.whyHere')} title={t('admin.channels.whyHere')}
className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100" className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100"
onClick={() => onShowTrace(e.id)} onClick={() => onShowTrace(e.id)}
> >
<HelpCircle className="h-4 w-4" /> <HelpCircle className="h-4 w-4" />
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
) )
} }

Some files were not shown because too many files have changed in this diff Show More